نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
@@ -0,0 +1,715 @@
"""Platform owner console: providers, models, routing policies and rate cards.
Everything in this router is invisible to customers. Two rules are enforced
here and tested:
1. Only a platform owner may call any of it.
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
a nested object. Only ``masked_key`` leaves this module.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.crypto import encrypt, mask
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.ai_control.models import (
AiModel,
Capability,
ModelCredential,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
RoutingPolicy,
)
from app.domain.audit.models import AuditAction
from app.domain.base import utcnow
from app.domain.identity.models import User
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
ProviderRepository,
RateCardRepository,
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import (
ObservabilityRepository,
UsageLedgerRepository,
)
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin"],
# Deny-by-default for the whole console. This is a router-level dependency on
# purpose: an endpoint added later is protected without its author having to
# remember. Relying on per-endpoint checks alone already caused a real
# privilege-escalation bug — every GET here once shipped without the check,
# letting any authenticated customer list all providers and every
# organization on the platform.
#
# Endpoints that also declare `owner: User = Depends(require_platform_owner)`
# do so because they *use* the object (for the audit actor); FastAPI caches
# the dependency within a request, so it is not evaluated twice.
dependencies=[Depends(require_platform_owner)],
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ProviderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
type: str
base_url: str
is_active: bool
created_at: datetime
class CreateProviderRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
type: ProviderType = ProviderType.MOCK
base_url: str = Field(default="", max_length=512)
class CredentialOut(BaseModel):
"""Deliberately has no field capable of holding the plaintext key."""
id: str
provider_id: str
name: str
masked_key: str
is_active: bool
class CreateCredentialRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
api_key: str = Field(min_length=4, max_length=512)
class ModelOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
provider_id: str
internal_name: str
display_name_for_admin: str
capabilities: list[str]
input_price_per_1m: float
output_price_per_1m: float
is_active: bool
class CreateModelRequest(BaseModel):
provider_id: str
internal_name: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(min_length=1, max_length=160)
capabilities: list[Capability] = Field(default_factory=list)
input_price_per_1m: float = Field(default=0.0, ge=0)
output_price_per_1m: float = Field(default=0.0, ge=0)
class RoutingPolicyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
description: str
mode: str
is_default: bool
is_active: bool
class CreateRoutingPolicyRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
mode: RoutingMode = RoutingMode.BALANCED
description: str = Field(default="", max_length=500)
is_default: bool = False
budget_limits_json: dict = Field(default_factory=dict)
fallback_policy_json: dict = Field(default_factory=dict)
class RateCardOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
unit: str
customer_price: float
internal_cost_estimate: float
is_active: bool
class UpsertRateCardRequest(BaseModel):
unit: RateUnit
name: str = Field(default="", max_length=120)
customer_price: float = Field(ge=0)
internal_cost_estimate: float = Field(default=0.0, ge=0)
is_active: bool = True
class AdminOrganizationOut(BaseModel):
id: str
name: str
slug: str
member_count: int
created_at: datetime
class AdminUsageRow(BaseModel):
organization_id: str
quantity: float
total_price: float
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
providers = await ProviderRepository(session).list()
return [
ProviderOut(
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
is_active=p.is_active, created_at=p.created_at,
)
for p in providers
]
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
async def create_provider(
payload: CreateProviderRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ProviderOut:
repo = ProviderRepository(session)
if await repo.get_by_name(payload.name) is not None:
raise ConflictError("ارائه‌دهنده‌ای با این نام وجود دارد.")
provider = await repo.create(
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="provider", target_id=provider.id,
metadata={"name": provider.name, "type": provider.type.value},
ip=ip, user_agent=ua,
)
return ProviderOut(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=provider.is_active,
created_at=provider.created_at,
)
@router.post(
"/providers/{provider_id}/credentials",
response_model=CredentialOut,
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
provider_id: str,
payload: CreateCredentialRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CredentialOut:
provider = await ProviderRepository(session).get(provider_id)
if provider is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
credential = await ModelCredentialRepository(session).create(
ModelCredential(
provider_id=provider_id,
name=payload.name,
# Encrypted at rest; the plaintext exists only inside this request.
encrypted_api_key=encrypt(payload.api_key),
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="credential", target_id=credential.id,
metadata={"provider_id": provider_id, "name": credential.name},
ip=ip, user_agent=ua,
)
return CredentialOut(
id=credential.id, provider_id=credential.provider_id, name=credential.name,
masked_key=mask(payload.api_key), is_active=credential.is_active,
)
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
async def list_credentials(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> list[CredentialOut]:
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
return [
CredentialOut(
id=c.id, provider_id=c.provider_id, name=c.name,
# We cannot recover the plaintext to mask it — and we must not try.
masked_key="••••••",
is_active=c.is_active,
)
for c in credentials
]
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
@router.get("/models", response_model=list[ModelOut])
async def list_models(
provider_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[ModelOut]:
models = await AiModelRepository(session).list(provider_id=provider_id)
return [
ModelOut(
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
display_name_for_admin=m.display_name_for_admin,
capabilities=list(m.capabilities),
input_price_per_1m=m.input_price_per_1m,
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
)
for m in models
]
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
async def create_model(
payload: CreateModelRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ModelOut:
if await ProviderRepository(session).get(payload.provider_id) is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
model = await AiModelRepository(session).create(
AiModel(
provider_id=payload.provider_id,
internal_name=payload.internal_name,
display_name_for_admin=payload.display_name_for_admin,
capabilities=[str(c) for c in payload.capabilities],
input_price_per_1m=payload.input_price_per_1m,
output_price_per_1m=payload.output_price_per_1m,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model.id,
metadata={"internal_name": model.internal_name},
ip=ip, user_agent=ua,
)
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m, is_active=model.is_active,
)
# ---------------------------------------------------------------------------
# Routing policies
# ---------------------------------------------------------------------------
@router.get("/routing-policies", response_model=list[RoutingPolicyOut])
async def list_routing_policies(
session: AsyncSession = Depends(get_session),
) -> list[RoutingPolicyOut]:
policies = await RoutingPolicyRepository(session).list()
return [
RoutingPolicyOut(
id=p.id, name=p.name, description=p.description, mode=p.mode.value,
is_default=p.is_default, is_active=p.is_active,
)
for p in policies
]
@router.post(
"/routing-policies", response_model=RoutingPolicyOut, status_code=status.HTTP_201_CREATED
)
async def create_routing_policy(
payload: CreateRoutingPolicyRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> RoutingPolicyOut:
policy = await RoutingPolicyRepository(session).create(
RoutingPolicy(
name=payload.name, mode=payload.mode, description=payload.description,
is_default=payload.is_default,
budget_limits_json=payload.budget_limits_json,
fallback_policy_json=payload.fallback_policy_json,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.ROUTING_CHANGE, actor_user_id=owner.id,
target_type="routing_policy", target_id=policy.id,
metadata={"name": policy.name, "mode": policy.mode.value},
ip=ip, user_agent=ua,
)
return RoutingPolicyOut(
id=policy.id, name=policy.name, description=policy.description,
mode=policy.mode.value, is_default=policy.is_default, is_active=policy.is_active,
)
# ---------------------------------------------------------------------------
# Rate cards
# ---------------------------------------------------------------------------
@router.get("/rate-cards", response_model=list[RateCardOut])
async def list_rate_cards(session: AsyncSession = Depends(get_session)) -> list[RateCardOut]:
cards = await RateCardRepository(session).list()
return [
RateCardOut(
id=c.id, name=c.name, unit=c.unit.value, customer_price=c.customer_price,
internal_cost_estimate=c.internal_cost_estimate, is_active=c.is_active,
)
for c in cards
]
@router.put("/rate-cards", response_model=RateCardOut)
async def upsert_rate_card(
payload: UpsertRateCardRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> RateCardOut:
card = await RateCardRepository(session).upsert(
RateCard(
name=payload.name or payload.unit.value.replace("_", " "),
unit=payload.unit,
customer_price=payload.customer_price,
internal_cost_estimate=payload.internal_cost_estimate,
is_active=payload.is_active,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, actor_user_id=owner.id,
target_type="rate_card", target_id=card.id,
metadata={"unit": card.unit.value, "customer_price": card.customer_price},
ip=ip, user_agent=ua,
)
return RateCardOut(
id=card.id, name=card.name, unit=card.unit.value,
customer_price=card.customer_price,
internal_cost_estimate=card.internal_cost_estimate, is_active=card.is_active,
)
# ---------------------------------------------------------------------------
# Cross-organization visibility
# ---------------------------------------------------------------------------
@router.get("/organizations", response_model=list[AdminOrganizationOut])
async def list_all_organizations(
session: AsyncSession = Depends(get_session),
) -> list[AdminOrganizationOut]:
organizations = await OrganizationRepository(session).list_all()
memberships = MembershipRepository(session)
out: list[AdminOrganizationOut] = []
for org in organizations:
members = await memberships.list_for_org(org.id)
out.append(
AdminOrganizationOut(
id=org.id, name=org.name, slug=org.slug,
member_count=len(members), created_at=org.created_at,
)
)
return out
@router.get("/usage", response_model=list[AdminUsageRow])
async def platform_usage(
session: AsyncSession = Depends(get_session),
) -> list[AdminUsageRow]:
rows = await UsageLedgerRepository(session).platform_totals()
return [
AdminUsageRow(
organization_id=org_id, quantity=quantity, total_price=total_price
)
for org_id, quantity, total_price in rows
]
@router.post("/validate-model-choice")
async def validate_model_choice(
mode: RoutingMode,
_: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Dry-run of routing: which model would this mode pick, and why?"""
model = await AiModelRepository(session).pick_for_mode(mode)
if model is None:
raise ValidationError("هیچ مدل فعالی برای این حالت مسیریابی وجود ندارد.")
return {
"mode": mode.value,
"model_id": model.id,
"internal_name": model.internal_name,
"capabilities": list(model.capabilities),
"estimated_input_price_per_1m": model.input_price_per_1m,
"estimated_output_price_per_1m": model.output_price_per_1m,
}
# ---------------------------------------------------------------------------
# Error console
#
# Every failure the platform catches lands in ``error_events``. This is where a
# platform owner reads them: grouped by fingerprint so one recurring bug is one
# line, with the full record (stack, request id, tenant, actor) one click away.
#
# Resolution is *per fingerprint* for the group view — fixing a bug means the
# whole group is dealt with — and per row for a single occurrence.
# ---------------------------------------------------------------------------
class ErrorEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
message: str
stack: str
severity: str
source: str
error_type: str
fingerprint: str
status_code: int | None
method: str
path: str
organization_id: str | None
user_id: str | None
run_id: str | None
request_id: str
ip: str
user_agent: str
metadata_json: dict
resolved_at: datetime | None
resolved_note: str
created_at: datetime
class ErrorGroupOut(BaseModel):
fingerprint: str
occurrences: int
open_count: int
first_seen: datetime
last_seen: datetime
kind: str
error_type: str
severity: str
source: str
path: str
sample_message: str
class ErrorSummaryOut(BaseModel):
open_by_severity: dict[str, int]
total_open: int
class ErrorListOut(BaseModel):
items: list[ErrorEventOut]
total: int
limit: int
offset: int
class ResolveErrorIn(BaseModel):
note: str = Field(default="", max_length=2000)
class ResolveErrorOut(BaseModel):
fingerprint: str
resolved: int
#: Newest-first page size ceiling. An unbounded ``limit`` would let one request
#: pull the entire table — including every stack trace — into memory.
MAX_ERROR_PAGE_SIZE = 200
@router.get("/errors", response_model=ErrorListOut)
async def list_errors(
severity: str | None = Query(default=None),
source: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
kind: str | None = Query(default=None),
organization_id: str | None = Query(default=None),
fingerprint: str | None = Query(default=None),
search: str | None = Query(default=None, max_length=200),
since: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
session: AsyncSession = Depends(get_session),
) -> ErrorListOut:
"""Individual occurrences, newest first."""
repo = ObservabilityRepository(session)
filters = {
"severity": severity,
"source": source,
"status": status_filter,
"kind": kind,
"organization_id": organization_id,
"fingerprint": fingerprint,
"search": search,
"since": since,
}
items = await repo.list_errors(limit=limit, offset=offset, **filters)
total = await repo.count_errors(**filters)
return ErrorListOut(
items=[ErrorEventOut.model_validate(item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.get("/errors/summary", response_model=ErrorSummaryOut)
async def error_summary(
session: AsyncSession = Depends(get_session),
) -> ErrorSummaryOut:
"""Unresolved counts, for the console's header tiles."""
by_severity = await ObservabilityRepository(session).count_open_by_severity()
return ErrorSummaryOut(
open_by_severity=by_severity, total_open=sum(by_severity.values())
)
# Declared before ``/errors/{error_id}`` on purpose: FastAPI matches routes in
# declaration order, so the other way round "groups" would be captured as an id.
@router.get("/errors/groups", response_model=list[ErrorGroupOut])
async def list_error_groups(
status_filter: str | None = Query(default=None, alias="status"),
severity: str | None = Query(default=None),
source: str | None = Query(default=None),
organization_id: str | None = Query(default=None),
since: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
session: AsyncSession = Depends(get_session),
) -> list[ErrorGroupOut]:
"""One row per distinct bug, most recently active first."""
groups = await ObservabilityRepository(session).list_error_groups(
limit=limit,
since=since,
status=status_filter,
severity=severity,
source=source,
organization_id=organization_id,
)
return [ErrorGroupOut(**group) for group in groups]
@router.get("/errors/{error_id}", response_model=ErrorEventOut)
async def get_error(
error_id: str,
session: AsyncSession = Depends(get_session),
) -> ErrorEventOut:
event = await ObservabilityRepository(session).get_error(error_id)
if event is None:
raise NotFoundError("خطای مورد نظر یافت نشد.")
return ErrorEventOut.model_validate(event)
@router.post("/errors/{error_id}/resolve", response_model=ResolveErrorOut)
async def resolve_error(
error_id: str,
payload: ResolveErrorIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ResolveErrorOut:
"""Mark one occurrence as dealt with."""
repo = ObservabilityRepository(session)
event = await repo.resolve_error(error_id, note=payload.note, resolved_at=utcnow())
if event is None:
raise NotFoundError("خطای مورد نظر یافت نشد.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ERROR_RESOLVE,
actor_user_id=owner.id,
target_type="error_event",
target_id=error_id,
metadata={"fingerprint": event.fingerprint, "note": payload.note[:200]},
ip=ip,
user_agent=ua,
)
return ResolveErrorOut(fingerprint=event.fingerprint, resolved=1)
@router.post("/errors/groups/{fingerprint}/resolve", response_model=ResolveErrorOut)
async def resolve_error_group(
fingerprint: str,
payload: ResolveErrorIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ResolveErrorOut:
"""Mark every open occurrence of one bug as dealt with."""
repo = ObservabilityRepository(session)
resolved = await repo.resolve_error_group(
fingerprint, note=payload.note, resolved_at=utcnow()
)
if resolved == 0:
raise NotFoundError("موردی برای بستن پیدا نشد؛ ممکن است قبلاً بسته شده باشد.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ERROR_RESOLVE,
actor_user_id=owner.id,
target_type="error_group",
target_id=fingerprint,
metadata={"resolved": resolved, "note": payload.note[:200]},
ip=ip,
user_agent=ua,
)
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
@router.post("/errors/groups/{fingerprint}/reopen", response_model=ResolveErrorOut)
async def reopen_error_group(
fingerprint: str,
_: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ResolveErrorOut:
"""Undo a resolution — for when the bug comes back."""
resolved = await ObservabilityRepository(session).reopen_error_group(fingerprint)
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
@@ -0,0 +1,182 @@
"""Authentication endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import AuthenticationError, ConflictError
from app.core.security import create_access_token, hash_password, verify_password
from app.domain.audit.models import AuditAction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.deps import client_meta, get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
name: str = Field(default="", max_length=160)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
email: str
name: str
avatar_url: str | None = None
is_platform_owner: bool = False
class OrganizationSummary(BaseModel):
id: str
name: str
slug: str
role: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class MeResponse(BaseModel):
user: UserOut
organizations: list[OrganizationSummary]
def _to_user_out(user: User) -> UserOut:
return UserOut(
id=user.id,
email=user.email,
name=user.display_name,
avatar_url=user.avatar_url,
is_platform_owner=user.is_platform_owner,
)
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def signup(
payload: SignupRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
users = UserRepository(session)
if await users.get_by_email(payload.email) is not None:
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
user = await users.create(
User(
email=payload.email,
password_hash=hash_password(payload.password),
name=payload.name.strip(),
)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.SIGNUP,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
@router.post("/login", response_model=TokenResponse)
async def login(
payload: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
user = await UserRepository(session).get_by_email(payload.email)
if user is None or not verify_password(payload.password, user.password_hash):
raise AuthenticationError("ایمیل یا رمز عبور نادرست است.")
if not user.is_active:
raise AuthenticationError("حساب کاربری غیرفعال است.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.LOGIN,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
"""JWTs are stateless; logout is an audit marker today and a token-revocation
hook once a denylist (Redis) is wired in."""
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.LOGOUT,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
@router.get("/me", response_model=MeResponse)
async def me(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
orgs = await OrganizationRepository(session).list_for_user(user.id)
return MeResponse(
user=_to_user_out(user),
organizations=[
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
for org, role in orgs
],
)
@router.get("/me/memberships")
async def my_memberships(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[dict]:
orgs = await OrganizationRepository(session).list_for_user(user.id)
memberships = MembershipRepository(session)
out: list[dict] = []
for org, role in orgs:
membership = await memberships.get(org.id, user.id)
out.append(
{
"organization_id": org.id,
"organization_name": org.name,
"role": role.value,
"status": membership.status.value if membership else "unknown",
}
)
return out
@@ -0,0 +1,294 @@
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
Reading billing requires ``billing.view``; changing it requires
``billing.manage``. A tenant can therefore never see — or move — another
organization's money.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.billing_service import BillingService
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_billing_service,
get_org_context,
)
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class UnitAggregateOut(BaseModel):
unit: str
quantity: float
total_price: float
class BillingSummaryOut(BaseModel):
balance: float
currency: str
low_balance: bool
low_balance_threshold: float
total_spend: float
by_unit: list[UnitAggregateOut]
by_employee: dict[str, float]
by_project: dict[str, float]
class UsageEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
unit: str
quantity: float
unit_price: float
total_price: float
project_id: str | None
employee_id: str | None
run_id: str | None
created_at: datetime
class InvoiceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
amount: float
currency: str
status: str
period_start: datetime | None
period_end: datetime | None
created_at: datetime
class PaymentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
amount: float
currency: str
status: str
provider: str
created_at: datetime
class AddCreditRequest(BaseModel):
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
class WalletOut(BaseModel):
balance: float
currency: str
low_balance: bool
class CheckoutRequest(BaseModel):
amount: float = Field(gt=0)
success_url: str = Field(default="", max_length=512)
cancel_url: str = Field(default="", max_length=512)
class CheckoutResponse(BaseModel):
payment_id: str
checkout_url: str
amount: float
class SettlePaymentRequest(BaseModel):
succeeded: bool = True
# ---------------------------------------------------------------------------
# Reads
# ---------------------------------------------------------------------------
@router.get("/summary", response_model=BillingSummaryOut)
async def billing_summary(
since: datetime | None = Query(default=None),
until: datetime | None = Query(default=None),
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> BillingSummaryOut:
context.require(CAN_VIEW_BILLING)
summary = await service.summary(
organization_id=context.org_id, since=since, until=until
)
return BillingSummaryOut(
balance=summary["balance"],
currency=summary["currency"],
low_balance=summary["low_balance"],
low_balance_threshold=summary["low_balance_threshold"],
total_spend=summary["total_spend"],
by_unit=[
UnitAggregateOut(
unit=str(a.unit), quantity=a.quantity, total_price=a.total_price
)
for a in summary["by_unit"]
],
by_employee=summary["by_employee"],
by_project=summary["by_project"],
)
@router.get("/usage-events", response_model=list[UsageEventOut])
async def list_usage_events(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> list[UsageEventOut]:
context.require(CAN_VIEW_BILLING)
rows = await service.list_usage_events(
organization_id=context.org_id, limit=limit, offset=offset
)
return [
UsageEventOut(
id=r.id, unit=r.unit, quantity=r.quantity, unit_price=r.unit_price,
total_price=r.total_price, project_id=r.project_id,
employee_id=r.employee_id, run_id=r.run_id, created_at=r.created_at,
)
for r in rows
]
@router.get("/invoices", response_model=list[InvoiceOut])
async def list_invoices(
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> list[InvoiceOut]:
context.require(CAN_VIEW_BILLING)
invoices = await service.list_invoices(context.org_id)
return [
InvoiceOut(
id=i.id, amount=i.amount, currency=i.currency, status=str(i.status),
period_start=i.period_start, period_end=i.period_end, created_at=i.created_at,
)
for i in invoices
]
@router.get("/payments", response_model=list[PaymentOut])
async def list_payments(
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> list[PaymentOut]:
context.require(CAN_VIEW_BILLING)
wallet = await service.wallet_for(context.org_id)
payments = await service.list_payments(context.org_id)
return [
PaymentOut(
id=p.id, amount=p.amount, currency=wallet.currency, status=str(p.status),
provider=p.provider, created_at=p.created_at,
)
for p in payments
]
# ---------------------------------------------------------------------------
# Writes
# ---------------------------------------------------------------------------
@router.post("/add-credit", response_model=WalletOut)
async def add_credit(
payload: AddCreditRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> WalletOut:
# A negative delta is a platform-owner-only clawback.
if payload.amount < 0:
if not context.user.is_platform_owner:
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
from app.core.errors import PermissionDeniedError
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.")
else:
context.require(CAN_MANAGE_BILLING)
wallet = await service.add_credit(
organization_id=context.org_id,
amount=payload.amount,
allow_negative=context.user.is_platform_owner,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="credit_wallet",
target_id=wallet.id,
metadata={"amount": payload.amount, "balance": wallet.balance},
ip=ip, user_agent=ua,
)
return WalletOut(
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
)
@router.post("/checkout", response_model=CheckoutResponse, status_code=status.HTTP_201_CREATED)
async def start_checkout(
payload: CheckoutRequest,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> CheckoutResponse:
context.require(CAN_MANAGE_BILLING)
payment, url = await service.start_checkout(
organization_id=context.org_id,
amount=payload.amount,
success_url=payload.success_url,
cancel_url=payload.cancel_url,
)
return CheckoutResponse(
payment_id=payment.id, checkout_url=url, amount=payment.amount
)
@router.post("/payments/{payment_id}/settle", response_model=PaymentOut)
async def settle_payment(
payment_id: str,
payload: SettlePaymentRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> PaymentOut:
"""Finalise a payment.
In production the payment provider calls a webhook; this endpoint is the
manual/dev equivalent. It is the only path that converts money into credit.
"""
context.require(CAN_MANAGE_BILLING)
payment = await service.settle_payment(
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
)
wallet = await service.wallet_for(context.org_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_PAYMENT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="payment", target_id=payment.id,
metadata={"status": str(payment.status), "amount": payment.amount},
ip=ip, user_agent=ua,
)
return PaymentOut(
id=payment.id, amount=payment.amount, currency=wallet.currency,
status=str(payment.status), provider=payment.provider,
created_at=payment.created_at,
)
@@ -0,0 +1,46 @@
"""Health and readiness endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_session
from app.infrastructure.adapters.registry import get_model_provider
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict:
"""Liveness. Must stay dependency-free so it answers during a DB outage."""
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
@router.get("/health/ready")
async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
"""Readiness: verifies the database and reports which adapters are active."""
checks: dict[str, object] = {}
try:
await session.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception as exc: # noqa: BLE001 - reported, not raised
checks["database"] = f"error: {exc.__class__.__name__}"
try:
model_health = await get_model_provider().health()
checks["model_provider"] = "ok" if model_health.ok else f"degraded: {model_health.detail}"
except Exception as exc: # noqa: BLE001
checks["model_provider"] = f"error: {exc.__class__.__name__}"
checks["adapters"] = {
"ai": settings.ai_default_provider,
"git": settings.git_provider,
"domain_router": settings.domain_router,
"sandbox": settings.sandbox_provider,
"payment": settings.payment_provider,
}
healthy = checks.get("database") == "ok"
return {"status": "ok" if healthy else "degraded", "checks": checks}
@@ -0,0 +1,251 @@
"""Knowledge Base endpoints.
Ingestion and deletion require ``employees.manage`` (the same trust level as
editing an employee's prompt); search only requires ``tasks.run``, because
reading your own organization's knowledge is part of doing work.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.knowledge_service import KnowledgeService
from app.core.database import get_session
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_knowledge_service,
get_org_context,
)
router = APIRouter(prefix="/organizations/{org_id}/knowledge", tags=["knowledge"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class KnowledgeSourceOut(BaseModel):
id: str
name: str
kind: str
project_id: str | None
created_at: datetime
chunk_count: int = 0
class CreateTextSourceRequest(BaseModel):
name: str = Field(min_length=2, max_length=255)
content: str = Field(min_length=1, max_length=400_000)
title: str = Field(default="", max_length=255)
project_id: str | None = None
class DocumentOut(BaseModel):
id: str
title: str
content: str
created_at: datetime
class EmbeddingJobOut(BaseModel):
id: str
status: str
error: str | None
class KnowledgeSourceDetailOut(KnowledgeSourceOut):
documents: list[DocumentOut]
job: EmbeddingJobOut | None
class SearchHitOut(BaseModel):
chunk_id: str
content: str
index: int
document_title: str
source_id: str
source_name: str
project_id: str | None
class CreateRetrievalPolicyRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
scope: dict = Field(default_factory=dict)
class RetrievalPolicyOut(BaseModel):
id: str
name: str
scope_json: dict
# ---------------------------------------------------------------------------
# Sources
# ---------------------------------------------------------------------------
@router.post(
"/sources", response_model=KnowledgeSourceOut, status_code=status.HTTP_201_CREATED
)
async def create_text_source(
payload: CreateTextSourceRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
session: AsyncSession = Depends(get_session),
) -> KnowledgeSourceOut:
context.require(CAN_MANAGE_EMPLOYEES)
source, chunk_count = await service.create_text_source(
organization_id=context.org_id,
name=payload.name,
content=payload.content,
project_id=payload.project_id,
title=payload.title,
)
ip, ua = client_meta(request)
await record_audit(
session, action="knowledge.source_create", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="knowledge_source",
target_id=source.id, metadata={"name": source.name, "chunks": chunk_count},
ip=ip, user_agent=ua,
)
return KnowledgeSourceOut(
id=source.id, name=source.name, kind=str(source.kind),
project_id=source.project_id, created_at=source.created_at,
chunk_count=chunk_count,
)
@router.get("/sources", response_model=list[KnowledgeSourceOut])
async def list_sources(
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[KnowledgeSourceOut]:
sources = await service.list_sources(context.org_id, limit=limit, offset=offset)
# One aggregate query for every source's chunk count. Asking per source
# inside the loop was an N+1: 100 sources meant 101 round trips.
chunk_counts = await service.chunk_counts([s.id for s in sources])
return [
KnowledgeSourceOut(
id=source.id, name=source.name, kind=str(source.kind),
project_id=source.project_id, created_at=source.created_at,
chunk_count=chunk_counts.get(source.id, 0),
)
for source in sources
]
@router.get("/sources/{source_id}", response_model=KnowledgeSourceDetailOut)
async def get_source(
source_id: str,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> KnowledgeSourceDetailOut:
source = await service.get_source_or_404(
organization_id=context.org_id, source_id=source_id
)
detail = await service.source_detail(source_id=source.id)
job = detail["job"]
return KnowledgeSourceDetailOut(
id=source.id, name=source.name, kind=str(source.kind),
project_id=source.project_id, created_at=source.created_at,
chunk_count=detail["chunk_count"],
documents=[
DocumentOut(
id=d.id, title=d.title, content=d.content, created_at=d.created_at
)
for d in detail["documents"]
],
job=(
EmbeddingJobOut(id=job.id, status=str(job.status), error=job.error)
if job is not None
else None
),
)
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
source_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
session: AsyncSession = Depends(get_session),
) -> None:
context.require(CAN_MANAGE_EMPLOYEES)
await service.delete_source(organization_id=context.org_id, source_id=source_id)
ip, ua = client_meta(request)
await record_audit(
session, action="knowledge.source_delete", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="knowledge_source",
target_id=source_id, ip=ip, user_agent=ua,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@router.get("/search", response_model=list[SearchHitOut])
async def search_knowledge(
q: str = Query(min_length=1, max_length=300),
project_id: str | None = Query(default=None),
limit: int = Query(default=5, ge=1, le=50),
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> list[SearchHitOut]:
context.require(CAN_RUN_TASKS)
hits = await service.search(
organization_id=context.org_id, query=q, project_id=project_id, limit=limit
)
return [SearchHitOut(**hit) for hit in hits]
# ---------------------------------------------------------------------------
# Retrieval policies
# ---------------------------------------------------------------------------
@router.get("/retrieval-policies", response_model=list[RetrievalPolicyOut])
async def list_retrieval_policies(
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> list[RetrievalPolicyOut]:
policies = await service.list_policies(context.org_id)
return [
RetrievalPolicyOut(id=p.id, name=p.name, scope_json=dict(p.scope_json))
for p in policies
]
@router.post(
"/retrieval-policies",
response_model=RetrievalPolicyOut,
status_code=status.HTTP_201_CREATED,
)
async def create_retrieval_policy(
payload: CreateRetrievalPolicyRequest,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> RetrievalPolicyOut:
context.require(CAN_MANAGE_EMPLOYEES)
policy = await service.create_policy(
organization_id=context.org_id, name=payload.name, scope=payload.scope
)
return RetrievalPolicyOut(
id=policy.id, name=policy.name, scope_json=dict(policy.scope_json)
)
@@ -0,0 +1,358 @@
"""Organization, membership and invite endpoints.
Every route below the organization prefix resolves its tenant through
``get_org_context``, so a caller can never read or mutate another tenant's data
by guessing an id.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.security import generate_token, hash_token
from app.domain.audit.models import AuditAction
from app.domain.base import ensure_unique_slug, slugify
from app.domain.identity.models import (
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
ROLE_RANK,
Invite,
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.infrastructure.db.models import CreditWalletRow
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.identity import (
InviteRepository,
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.deps import OrgContext, client_meta, get_current_user, get_org_context
router = APIRouter(prefix="/organizations", tags=["organizations"])
INVITE_TTL_DAYS = 14
class CreateOrganizationRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
class OrganizationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
slug: str
owner_user_id: str
class OrganizationWithRole(OrganizationOut):
role: str
class MemberOut(BaseModel):
id: str
user_id: str
email: str = ""
name: str = ""
role: str
status: str
created_at: datetime
class InviteRequest(BaseModel):
email: EmailStr
role: Role = Role.MEMBER
class InviteOut(BaseModel):
id: str
email: str
role: str
expires_at: datetime
invite_token: str
invite_url: str
class UpdateMemberRequest(BaseModel):
role: Role | None = None
status: MembershipStatus | None = None
class AuditEventOut(BaseModel):
id: str
action: str
actor_user_id: str | None
target_type: str
target_id: str
created_at: datetime
def _org_out(org: Organization) -> OrganizationOut:
return OrganizationOut(
id=org.id, name=org.name, slug=org.slug, owner_user_id=org.owner_user_id
)
@router.post("", response_model=OrganizationWithRole, status_code=status.HTTP_201_CREATED)
async def create_organization(
payload: CreateOrganizationRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> OrganizationWithRole:
orgs = OrganizationRepository(session)
base = slugify(payload.name)
slug = ensure_unique_slug(base, await orgs.list_all_slugs())
org = await orgs.create(
Organization(name=payload.name.strip(), slug=slug, owner_user_id=user.id)
)
await MembershipRepository(session).add(
Membership(
organization_id=org.id,
user_id=user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
# Every organization gets a wallet up front so billing views are never empty.
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ORG_CREATE,
organization_id=org.id,
actor_user_id=user.id,
target_type="organization",
target_id=org.id,
metadata={"name": org.name, "slug": org.slug},
ip=ip,
user_agent=ua,
)
await session.flush()
return OrganizationWithRole(
id=org.id, name=org.name, slug=org.slug,
owner_user_id=org.owner_user_id, role=Role.OWNER.value,
)
@router.get("", response_model=list[OrganizationWithRole])
async def list_organizations(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[OrganizationWithRole]:
orgs = await OrganizationRepository(session).list_for_user(user.id)
return [
OrganizationWithRole(
id=org.id, name=org.name, slug=org.slug,
owner_user_id=org.owner_user_id, role=role.value,
)
for org, role in orgs
]
@router.get("/{org_id}", response_model=OrganizationWithRole)
async def get_organization(context: OrgContext = Depends(get_org_context)) -> OrganizationWithRole:
return OrganizationWithRole(
id=context.organization.id,
name=context.organization.name,
slug=context.organization.slug,
owner_user_id=context.organization.owner_user_id,
role=context.membership.role.value,
)
@router.get("/{org_id}/members", response_model=list[MemberOut])
async def list_members(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[MemberOut]:
memberships = await MembershipRepository(session).list_for_org(context.org_id)
users = UserRepository(session)
out: list[MemberOut] = []
for membership in memberships:
member_user = await users.get(membership.user_id)
out.append(
MemberOut(
id=membership.id,
user_id=membership.user_id,
email=member_user.email if member_user else "",
name=member_user.display_name if member_user else "",
role=membership.role.value,
status=membership.status.value,
created_at=membership.created_at,
)
)
return out
@router.post(
"/{org_id}/invites", response_model=InviteOut, status_code=status.HTTP_201_CREATED
)
async def create_invite(
payload: InviteRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> InviteOut:
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("دعوت عضو نیازمند دسترسی مدیریت اعضا است.")
if ROLE_RANK[payload.role] >= ROLE_RANK[Role.OWNER]:
raise ValidationError("نقش owner از طریق دعوت قابل واگذاری نیست.")
raw_token = generate_token()
invite = await InviteRepository(session).create(
Invite(
organization_id=context.org_id,
email=str(payload.email),
role=payload.role,
token_hash=hash_token(raw_token),
invited_by_user_id=context.user.id,
expires_at=datetime.now(UTC) + timedelta(days=INVITE_TTL_DAYS),
)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_INVITE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="invite",
target_id=invite.id,
metadata={"email": invite.email, "role": invite.role.value},
ip=ip,
user_agent=ua,
)
return InviteOut(
id=invite.id,
email=invite.email,
role=invite.role.value,
expires_at=invite.expires_at,
invite_token=raw_token,
invite_url=f"/accept-invite?token={raw_token}",
)
@router.patch("/{org_id}/members/{member_id}", response_model=MemberOut)
async def update_member(
member_id: str,
payload: UpdateMemberRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> MemberOut:
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("تغییر نقش عضو نیازمند دسترسی مدیریت اعضا است.")
memberships = MembershipRepository(session)
target = await memberships.get_by_id(member_id)
if target is None or target.organization_id != context.org_id:
raise NotFoundError("عضو یافت نشد.")
# An admin may not outrank themselves or promote anyone to owner.
if target.role == Role.OWNER and context.user.id != target.user_id:
raise PermissionDeniedError("نقش owner فقط توسط خودش قابل تغییر است.")
if payload.role is not None and ROLE_RANK[payload.role] > ROLE_RANK[context.membership.role]:
raise PermissionDeniedError("نمی‌توانید نقشی بالاتر از نقش خود واگذار کنید.")
if payload.role is not None:
if target.role == Role.OWNER and await memberships.count_owners(context.org_id) <= 1:
raise ValidationError("آخرین owner سازمان را نمی‌توان تنزل داد.")
await memberships.set_role(member_id, payload.role)
if payload.status is not None:
await memberships.set_status(member_id, payload.status)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_ROLE_CHANGE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="membership",
target_id=member_id,
metadata={
"role": payload.role.value if payload.role else None,
"status": payload.status.value if payload.status else None,
},
ip=ip,
user_agent=ua,
)
updated = await memberships.get_by_id(member_id)
assert updated is not None
member_user = await UserRepository(session).get(updated.user_id)
return MemberOut(
id=updated.id,
user_id=updated.user_id,
email=member_user.email if member_user else "",
name=member_user.display_name if member_user else "",
role=updated.role.value,
status=updated.status.value,
created_at=updated.created_at,
)
@router.delete("/{org_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
member_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> None:
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("حذف عضو نیازمند دسترسی مدیریت اعضا است.")
memberships = MembershipRepository(session)
target = await memberships.get_by_id(member_id)
if target is None or target.organization_id != context.org_id:
raise NotFoundError("عضو یافت نشد.")
if target.role == Role.OWNER:
raise ValidationError("owner سازمان را نمی‌توان حذف کرد؛ ابتدا نقش را تغییر دهید.")
await memberships.remove(member_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_REMOVE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="membership",
target_id=member_id,
ip=ip,
user_agent=ua,
)
@router.get("/{org_id}/audit", response_model=list[AuditEventOut])
async def list_audit(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[AuditEventOut]:
if not context.membership.has(CAN_VIEW_AUDIT):
raise PermissionDeniedError("مشاهدهٔ audit log نیازمند دسترسی مدیر است.")
events = await AuditRepository(session).list_for_organization(
organization_id=context.org_id, limit=limit, offset=offset
)
return [
AuditEventOut(
id=e.id, action=e.action, actor_user_id=e.actor_user_id,
target_type=e.target_type, target_id=e.target_id, created_at=e.created_at,
)
for e in events
]
@@ -0,0 +1,375 @@
"""Project, repository and preview-domain endpoints.
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
context is resolved by ``get_org_context`` before any handler body runs.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.project_service import ProjectService
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
router = APIRouter(
prefix="/organizations/{org_id}/projects", tags=["projects"]
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ProjectOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
name: str
slug: str
description: str
default_branch: str
created_at: datetime
class CreateProjectRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
description: str = Field(default="", max_length=2000)
class RepoOut(BaseModel):
id: str
provider: str
owner: str
repo_name: str
clone_url: str
web_url: str
default_branch: str
private: bool = True
class RepoStatusOut(BaseModel):
exists: bool
private: bool
default_branch: str
branches: list[str]
open_pull_requests: int
class CreateBranchRequest(BaseModel):
branch_name: str = Field(min_length=1, max_length=120)
from_branch: str = Field(default="main", max_length=120)
class BranchOut(BaseModel):
name: str
sha: str
class OpenPullRequestRequest(BaseModel):
head: str = Field(min_length=1, max_length=120)
base: str = Field(default="main", max_length=120)
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
class PullRequestOut(BaseModel):
number: int
url: str
state: str
class PreviewDomainOut(BaseModel):
id: str
host: str
port: int
status: str
protected: bool
url: str
class CreatePreviewDomainRequest(BaseModel):
subdomain: str = Field(min_length=3, max_length=40)
port: int | None = Field(default=None, ge=1, le=65535)
class AllocatePortRequest(BaseModel):
service_name: str = Field(default="web", max_length=80)
class PortOut(BaseModel):
port: int
service_name: str
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
async def create_project(
payload: CreateProjectRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> ProjectOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.create_project(
organization_id=context.org_id,
organization_slug=context.organization.slug,
created_by_user_id=context.user.id,
name=payload.name,
description=payload.description,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="project", target_id=project.id,
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
)
return ProjectOut(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
default_branch=project.default_branch, created_at=project.created_at,
)
@router.get("", response_model=list[ProjectOut])
async def list_projects(
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[ProjectOut]:
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
return [
ProjectOut(
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
description=p.description, default_branch=p.default_branch,
created_at=p.created_at,
)
for p in projects
]
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> ProjectOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
return ProjectOut(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
default_branch=project.default_branch, created_at=project.created_at,
)
# ---------------------------------------------------------------------------
# Repository
# ---------------------------------------------------------------------------
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
async def ensure_repo(
project_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> RepoOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
repo = await service.ensure_repository(
organization_slug=context.organization.slug, project=project
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
ip=ip, user_agent=ua,
)
return RepoOut(
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
clone_url=repo.clone_url, web_url=repo.web_url,
default_branch=repo.default_branch, private=True,
)
@router.get("/{project_id}/repo", response_model=RepoOut)
async def get_repo(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> RepoOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
repo = await service.repos.get_for_project(project.id)
if repo is None:
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
# clone_url is a plain https URL; credentials are never included.
return RepoOut(
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
clone_url=repo.clone_url, web_url=repo.web_url,
default_branch=repo.default_branch, private=True,
)
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
async def get_repo_status(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> RepoStatusOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
status_result = await service.repo_status(project=project)
return RepoStatusOut(
exists=status_result.exists,
private=status_result.private,
default_branch=status_result.default_branch,
branches=list(status_result.branches),
open_pull_requests=status_result.open_pull_requests,
)
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
async def create_branch(
project_id: str,
payload: CreateBranchRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> BranchOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
branch = await service.git.create_branch(
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
)
return BranchOut(name=branch.name, sha=branch.sha)
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
async def open_pull_request(
project_id: str,
payload: OpenPullRequestRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> PullRequestOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
pr = await service.git.open_pull_request(
project_id=project.id, head=payload.head, base=payload.base,
title=payload.title, body=payload.body,
)
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
# ---------------------------------------------------------------------------
# Preview domains
# ---------------------------------------------------------------------------
@router.post("/{project_id}/preview/ports", response_model=PortOut)
async def allocate_port(
project_id: str,
payload: AllocatePortRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> PortOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
port = await service.allocate_port(project=project, service_name=payload.service_name)
return PortOut(port=port, service_name=payload.service_name)
@router.post(
"/{project_id}/preview/domains",
response_model=PreviewDomainOut,
status_code=status.HTTP_201_CREATED,
)
async def create_preview_domain(
project_id: str,
payload: CreatePreviewDomainRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> PreviewDomainOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
record = await service.create_preview_domain(
project=project,
requested_subdomain=payload.subdomain,
created_by_user_id=context.user.id,
port=payload.port,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="preview_domain",
target_id=record["id"],
metadata={"host": record["host"], "port": record["port"]},
ip=ip, user_agent=ua,
)
return PreviewDomainOut(
id=record["id"], host=record["host"], port=record["port"],
status=record["status"], protected=record["protected"], url=record["url"],
)
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
async def list_preview_domains(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> list[PreviewDomainOut]:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
records = await service.list_preview_domains(project=project)
return [
PreviewDomainOut(
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
protected=r["protected"], url=r["url"],
)
for r in records
]
@router.delete(
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_preview_domain(
project_id: str,
domain_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> None:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
await service.remove_preview_domain(project=project, domain_id=domain_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
ip=ip, user_agent=ua,
)
@@ -0,0 +1,449 @@
"""Sandbox Gateway endpoints.
Note what is **absent**: there is no route that accepts a shell command and runs
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
policy timeout and output cap, and is metered into the usage ledger.
Running a command requires ``sandbox.use``; tearing a workspace down requires
``projects.manage`` because it destroys work.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
from app.domain.sandbox.models import NetworkPolicy
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_sandbox_service,
)
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class SandboxTemplateOut(BaseModel):
id: str
name: str
provider: str
image: str
description: str
default_cpu: float
default_memory_mb: int
default_disk_mb: int
class WorkspaceOut(BaseModel):
id: str
project_id: str
template_id: str
provider: str
status: str
created_at: datetime
class CreateWorkspaceRequest(BaseModel):
project_id: str
template_id: str
class SessionOut(BaseModel):
id: str
workspace_id: str
status: str
started_at: datetime
last_active_at: datetime
class StartSessionRequest(BaseModel):
workspace_id: str
class CommandRunOut(BaseModel):
id: str
command: str
exit_code: int | None
stdout: str
stderr: str
duration_ms: int
truncated: bool
created_at: datetime
class RunCommandRequest(BaseModel):
command: str = Field(min_length=1, max_length=4_000)
class FileReadOut(BaseModel):
path: str
content: str
class ReadFileRequest(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
class WriteFileRequest(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
content: str = Field(max_length=1_000_000)
class SnapshotOut(BaseModel):
id: str
name: str
created_at: datetime
class CreateSnapshotRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
class PreviewUrlOut(BaseModel):
port: int
url: str
class ResourceLimitOut(BaseModel):
cpu: float
memory_mb: int
disk_mb: int
timeout_seconds: int
max_output_bytes: int
network_policy: str
class SetLimitsRequest(BaseModel):
scope: str = "organization"
scope_id: str | None = None
cpu: float = Field(default=1.0, gt=0, le=16)
memory_mb: int = Field(default=1024, ge=128, le=65_536)
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
timeout_seconds: int = Field(default=120, ge=1, le=900)
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
# ---------------------------------------------------------------------------
# Templates & limits
# ---------------------------------------------------------------------------
@router.get("/templates", response_model=list[SandboxTemplateOut])
async def list_templates(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> list[SandboxTemplateOut]:
templates = await service.list_templates()
return [
SandboxTemplateOut(
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
description=t.description, default_cpu=t.default_cpu,
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
)
for t in templates
]
@router.get("/limits", response_model=ResourceLimitOut)
async def get_limits(
project_id: str | None = Query(default=None),
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> ResourceLimitOut:
limit = await service.effective_limits(
organization_id=context.org_id, project_id=project_id
)
return ResourceLimitOut(
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
network_policy=str(limit.network_policy),
)
@router.put("/limits", response_model=ResourceLimitOut)
async def set_limits(
payload: SetLimitsRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> ResourceLimitOut:
context.require(CAN_MANAGE_PROJECTS)
limit = await service.set_limits(
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
timeout_seconds=payload.timeout_seconds,
max_output_bytes=payload.max_output_bytes,
network_policy=payload.network_policy,
)
return ResourceLimitOut(
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
network_policy=str(limit.network_policy),
)
# ---------------------------------------------------------------------------
# Workspaces
# ---------------------------------------------------------------------------
@router.post(
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
)
async def create_workspace(
payload: CreateWorkspaceRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
context.require(CAN_USE_SANDBOX)
workspace = await service.create_workspace(
organization_id=context.org_id,
project_id=payload.project_id,
template_id=payload.template_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action="sandbox.workspace_create", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
ip=ip, user_agent=ua,
)
return WorkspaceOut(
id=workspace.id, project_id=workspace.project_id,
template_id=workspace.template_id, provider=str(workspace.provider),
status=str(workspace.status), created_at=workspace.created_at,
)
@router.get("/workspaces", response_model=list[WorkspaceOut])
async def list_workspaces(
project_id: str = Query(min_length=1),
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[WorkspaceOut]:
workspaces = await service.list_workspaces(
organization_id=context.org_id,
project_id=project_id,
limit=limit,
offset=offset,
)
return [
WorkspaceOut(
id=w.id, project_id=w.project_id, template_id=w.template_id,
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
)
for w in workspaces
]
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
async def destroy_workspace(
workspace_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
context.require(CAN_MANAGE_PROJECTS)
workspace = await service.destroy_workspace(
organization_id=context.org_id, workspace_id=workspace_id
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="workspace",
target_id=workspace.id, ip=ip, user_agent=ua,
)
return WorkspaceOut(
id=workspace.id, project_id=workspace.project_id,
template_id=workspace.template_id, provider=str(workspace.provider),
status=str(workspace.status), created_at=workspace.created_at,
)
# ---------------------------------------------------------------------------
# Sessions
# ---------------------------------------------------------------------------
@router.post(
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
)
async def start_session(
payload: StartSessionRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
context.require(CAN_USE_SANDBOX)
sandbox_session = await service.start_session(
organization_id=context.org_id, workspace_id=payload.workspace_id
)
return SessionOut(
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
last_active_at=sandbox_session.last_active_at,
)
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
async def stop_session(
session_id: str,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
context.require(CAN_USE_SANDBOX)
sandbox_session = await service.stop_session(
organization_id=context.org_id, session_id=session_id
)
return SessionOut(
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
last_active_at=sandbox_session.last_active_at,
)
# ---------------------------------------------------------------------------
# Commands & files
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
async def run_command(
session_id: str,
payload: RunCommandRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> CommandRunOut:
context.require(CAN_USE_SANDBOX)
run, _result = await service.run_command(
organization_id=context.org_id,
session_id=session_id,
command=payload.command,
user_id=context.user.id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="sandbox_session",
target_id=session_id,
# The command itself is recorded, but never a secret from the environment.
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
ip=ip, user_agent=ua,
)
return CommandRunOut(
id=run.id, command=run.command, exit_code=run.exit_code,
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
truncated=run.truncated, created_at=run.created_at,
)
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
async def list_commands(
session_id: str,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> list[CommandRunOut]:
await service.get_session_or_404(
organization_id=context.org_id, session_id=session_id
)
runs = await service.list_commands(session_id=session_id)
return [
CommandRunOut(
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
created_at=r.created_at,
)
for r in runs
]
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
async def read_file(
session_id: str,
payload: ReadFileRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> FileReadOut:
context.require(CAN_USE_SANDBOX)
content = await service.read_file(
organization_id=context.org_id, session_id=session_id, path=payload.path
)
return FileReadOut(path=payload.path, content=content)
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
async def write_file(
session_id: str,
payload: WriteFileRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> None:
context.require(CAN_USE_SANDBOX)
await service.write_file(
organization_id=context.org_id, session_id=session_id,
path=payload.path, content=payload.content,
)
# ---------------------------------------------------------------------------
# Snapshots & preview
# ---------------------------------------------------------------------------
@router.post(
"/sessions/{session_id}/snapshots",
response_model=SnapshotOut,
status_code=status.HTTP_201_CREATED,
)
async def create_snapshot(
session_id: str,
payload: CreateSnapshotRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SnapshotOut:
context.require(CAN_USE_SANDBOX)
snapshot = await service.snapshot(
organization_id=context.org_id, session_id=session_id, name=payload.name
)
return SnapshotOut(
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
)
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
async def get_preview_url(
session_id: str,
port: int = Query(ge=1, le=65535),
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> PreviewUrlOut:
context.require(CAN_USE_SANDBOX)
preview = await service.get_preview_url(
organization_id=context.org_id, session_id=session_id, port=port
)
return PreviewUrlOut(port=preview.port, url=preview.url)
@@ -0,0 +1,803 @@
"""AI Workforce, Manager Chat, Tasks and Runs.
Two things are load-bearing in this module:
* **Model identity never leaves.** ``EmployeeOut`` and ``RunOut`` carry a routing
policy id at most. There is no field capable of holding a model or provider
name, so a future refactor cannot accidentally leak one.
* **Every route resolves its tenant** through ``get_org_context``, and every
nested id (employee, thread, task, run) is re-scoped to that tenant.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.workforce_service import WorkforceService
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.domain.workforce.models import (
EmployeeStatus,
RoleKey,
TaskStatus,
)
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_workforce_service,
)
router = APIRouter(prefix="/organizations/{org_id}", tags=["workforce"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class AgentTemplateOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
role_key: str
description: str
default_tools_json: list[str]
class RoutingChoiceOut(BaseModel):
"""A routing policy as a *customer* may see it.
Name and description only. Deliberately omits ``mode`` and every field that
could hint at which model sits behind the choice.
"""
id: str
name: str
description: str
class ToolSpecOut(BaseModel):
key: str
label: str
description: str
requires_approval: bool
destructive: bool
category: str
class SetToolPermissionRequest(BaseModel):
allowed: bool
class DecideApprovalRequest(BaseModel):
approve: bool
note: str = Field(default="", max_length=1000)
class EmployeeOut(BaseModel):
id: str
organization_id: str
project_id: str | None
template_id: str | None
name: str
role_key: str
status: str
# A routing policy id — never a model or provider name.
routing_policy_id: str | None
created_at: datetime
class EmployeeDetailOut(EmployeeOut):
prompt_versions: list[PromptVersionOut]
tool_permissions: list[ToolPermissionOut]
open_task_count: int
class CreateEmployeeRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
role_key: RoleKey
template_id: str | None = None
project_id: str | None = None
routing_policy_id: str | None = None
class PromptVersionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
version: int
content: str
created_at: datetime
class ToolPermissionOut(BaseModel):
tool_key: str
allowed: bool
# ``EmployeeDetailOut`` refers to the two models above by name. With
# ``from __future__ import annotations`` every annotation is a string, so Pydantic
# must be told to resolve them once the names exist.
EmployeeDetailOut.model_rebuild()
class UpdateEmployeeRequest(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=160)
status: EmployeeStatus | None = None
project_id: str | None = None
routing_policy_id: str | None = None
class CreatePromptVersionRequest(BaseModel):
content: str = Field(min_length=1, max_length=20_000)
class ThreadOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
project_id: str | None
title: str
created_at: datetime
class MessageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
thread_id: str
role: str
content: str
employee_id: str | None
created_at: datetime
class ThreadDetailOut(ThreadOut):
messages: list[MessageOut]
class CreateThreadRequest(BaseModel):
title: str = Field(default="", max_length=255)
project_id: str | None = None
class PostMessageRequest(BaseModel):
content: str = Field(min_length=1, max_length=20_000)
employee_id: str | None = None
class PostMessageResponse(BaseModel):
user_message: MessageOut
assistant_message: MessageOut
class TaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
project_id: str | None
thread_id: str | None
title: str
description: str
status: str
assigned_employee_id: str | None
created_at: datetime
updated_at: datetime
class CreateTaskRequest(BaseModel):
title: str = Field(min_length=2, max_length=255)
description: str = Field(default="", max_length=10_000)
project_id: str | None = None
thread_id: str | None = None
assigned_employee_id: str | None = None
class RunOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
task_id: str
employee_id: str | None
status: str
error: str | None
started_at: datetime | None
finished_at: datetime | None
created_at: datetime
class RunStepOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
index: int
kind: str
title: str
payload_json: dict
created_at: datetime
class ToolCallOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tool_key: str
status: str
requires_approval: bool
arguments_json: dict
result_json: dict
created_at: datetime
class ArtifactOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
kind: str
content_ref: str
size_bytes: int
run_id: str | None
created_at: datetime
class RunDetailOut(RunOut):
steps: list[RunStepOut]
tool_calls: list[ToolCallOut]
artifacts: list[ArtifactOut]
class TaskDetailOut(TaskOut):
runs: list[RunOut]
class StartRunRequest(BaseModel):
employee_id: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _employee_out(employee) -> EmployeeOut:
return EmployeeOut(
id=employee.id, organization_id=employee.organization_id,
project_id=employee.project_id, template_id=employee.template_id,
name=employee.name, role_key=str(employee.role_key),
status=str(employee.status), routing_policy_id=employee.routing_policy_id,
created_at=employee.created_at,
)
def _thread_out(thread) -> ThreadOut:
return ThreadOut(
id=thread.id, organization_id=thread.organization_id,
project_id=thread.project_id, title=thread.title, created_at=thread.created_at,
)
def _task_out(task) -> TaskOut:
return TaskOut(
id=task.id, organization_id=task.organization_id, project_id=task.project_id,
thread_id=task.thread_id, title=task.title, description=task.description,
status=str(task.status), assigned_employee_id=task.assigned_employee_id,
created_at=task.created_at, updated_at=task.updated_at,
)
def _run_out(run) -> RunOut:
return RunOut(
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
status=str(run.status), error=run.error, started_at=run.started_at,
finished_at=run.finished_at, created_at=run.created_at,
)
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
@router.get("/agent-templates", response_model=list[AgentTemplateOut])
async def list_agent_templates(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[AgentTemplateOut]:
templates = await service.list_templates()
return [
AgentTemplateOut(
id=t.id, name=t.name, role_key=str(t.role_key), description=t.description,
default_tools_json=list(t.default_tools_json),
)
for t in templates
]
@router.get("/routing-choices", response_model=list[RoutingChoiceOut])
async def list_routing_choices(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[RoutingChoiceOut]:
"""Let a customer pick *how* an employee should work, without naming a model."""
policies = await service.list_routing_choices()
return [
RoutingChoiceOut(id=p.id, name=p.name, description=p.description) for p in policies
]
# ---------------------------------------------------------------------------
# Employees
# ---------------------------------------------------------------------------
@router.post(
"/employees", response_model=EmployeeOut, status_code=status.HTTP_201_CREATED
)
async def create_employee(
payload: CreateEmployeeRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> EmployeeOut:
context.require(CAN_MANAGE_EMPLOYEES)
employee = await service.create_employee(
organization_id=context.org_id,
created_by_user_id=context.user.id,
name=payload.name,
role_key=payload.role_key,
template_id=payload.template_id,
project_id=payload.project_id,
routing_policy_id=payload.routing_policy_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.EMPLOYEE_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
metadata={"name": employee.name, "role_key": str(employee.role_key)},
ip=ip, user_agent=ua,
)
return _employee_out(employee)
@router.get("/employees", response_model=list[EmployeeOut])
async def list_employees(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[EmployeeOut]:
return [
_employee_out(e)
for e in await service.list_employees(context.org_id, limit=limit, offset=offset)
]
@router.get("/employees/{employee_id}", response_model=EmployeeDetailOut)
async def get_employee(
employee_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> EmployeeDetailOut:
employee = await service.get_employee_or_404(
organization_id=context.org_id, employee_id=employee_id
)
base = _employee_out(employee)
return EmployeeDetailOut(
**base.model_dump(),
prompt_versions=[
PromptVersionOut(
id=v.id, version=v.version, content=v.content, created_at=v.created_at
)
for v in await service.list_prompt_versions(employee.id)
],
tool_permissions=[
ToolPermissionOut(tool_key=p.tool_key, allowed=p.allowed)
for p in await service.list_tool_permissions(employee.id)
],
open_task_count=await service.tasks.count_open_for_employee(employee.id),
)
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
async def update_employee(
employee_id: str,
payload: UpdateEmployeeRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> EmployeeOut:
context.require(CAN_MANAGE_EMPLOYEES)
employee = await service.update_employee(
organization_id=context.org_id,
employee_id=employee_id,
name=payload.name,
status=payload.status,
project_id=payload.project_id,
routing_policy_id=payload.routing_policy_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.EMPLOYEE_UPDATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
metadata={"status": str(employee.status)},
ip=ip, user_agent=ua,
)
return _employee_out(employee)
@router.post(
"/employees/{employee_id}/prompt-versions",
response_model=PromptVersionOut,
status_code=status.HTTP_201_CREATED,
)
async def add_prompt_version(
employee_id: str,
payload: CreatePromptVersionRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> PromptVersionOut:
context.require(CAN_MANAGE_EMPLOYEES)
# Confirms tenant ownership before writing anything.
await service.get_employee_or_404(
organization_id=context.org_id, employee_id=employee_id
)
version = await service.add_prompt_version(
employee_id=employee_id, content=payload.content,
created_by_user_id=context.user.id,
)
return PromptVersionOut(
id=version.id, version=version.version, content=version.content,
created_at=version.created_at,
)
# ---------------------------------------------------------------------------
# Threads & messages
# ---------------------------------------------------------------------------
@router.post(
"/threads", response_model=ThreadOut, status_code=status.HTTP_201_CREATED
)
async def create_thread(
payload: CreateThreadRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> ThreadOut:
thread = await service.create_thread(
organization_id=context.org_id, created_by_user_id=context.user.id,
title=payload.title, project_id=payload.project_id,
)
return _thread_out(thread)
@router.get("/threads", response_model=list[ThreadOut])
async def list_threads(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[ThreadOut]:
return [
_thread_out(t)
for t in await service.list_threads(context.org_id, limit=limit, offset=offset)
]
@router.get("/threads/{thread_id}", response_model=ThreadDetailOut)
async def get_thread(
thread_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> ThreadDetailOut:
thread = await service.get_thread_or_404(
organization_id=context.org_id, thread_id=thread_id
)
messages = await service.list_messages(thread.id)
base = _thread_out(thread)
return ThreadDetailOut(
**base.model_dump(),
messages=[
MessageOut(
id=m.id, thread_id=m.thread_id, role=str(m.role), content=m.content,
employee_id=m.employee_id, created_at=m.created_at,
)
for m in messages
],
)
@router.post(
"/threads/{thread_id}/messages",
response_model=PostMessageResponse,
status_code=status.HTTP_201_CREATED,
)
async def post_message(
thread_id: str,
payload: PostMessageRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> PostMessageResponse:
context.require(CAN_RUN_TASKS)
user_message, assistant_message = await service.post_user_message(
organization_id=context.org_id,
thread_id=thread_id,
content=payload.content,
user_id=context.user.id,
employee_id=payload.employee_id,
)
return PostMessageResponse(
user_message=MessageOut(
id=user_message.id, thread_id=user_message.thread_id,
role=str(user_message.role), content=user_message.content,
employee_id=user_message.employee_id, created_at=user_message.created_at,
),
assistant_message=MessageOut(
id=assistant_message.id, thread_id=assistant_message.thread_id,
role=str(assistant_message.role), content=assistant_message.content,
employee_id=assistant_message.employee_id,
created_at=assistant_message.created_at,
),
)
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@router.post("/tasks", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
async def create_task(
payload: CreateTaskRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> TaskOut:
context.require(CAN_RUN_TASKS)
task = await service.create_task(
organization_id=context.org_id,
created_by_user_id=context.user.id,
title=payload.title,
description=payload.description,
project_id=payload.project_id,
thread_id=payload.thread_id,
assigned_employee_id=payload.assigned_employee_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.TASK_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="task", target_id=task.id,
metadata={"title": task.title}, ip=ip, user_agent=ua,
)
return _task_out(task)
@router.get("/tasks", response_model=list[TaskOut])
async def list_tasks(
status_filter: TaskStatus | None = Query(default=None, alias="status"),
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[TaskOut]:
tasks = await service.list_tasks(
context.org_id, status=status_filter, limit=limit, offset=offset
)
return [_task_out(t) for t in tasks]
@router.get("/tasks/{task_id}", response_model=TaskDetailOut)
async def get_task(
task_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> TaskDetailOut:
task = await service.get_task_or_404(organization_id=context.org_id, task_id=task_id)
runs = await service.list_task_runs(task.id)
base = _task_out(task)
return TaskDetailOut(**base.model_dump(), runs=[_run_out(r) for r in runs])
# ---------------------------------------------------------------------------
# Runs
# ---------------------------------------------------------------------------
@router.post(
"/tasks/{task_id}/runs",
response_model=RunDetailOut,
status_code=status.HTTP_201_CREATED,
)
async def start_run(
task_id: str,
payload: StartRunRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> RunDetailOut:
context.require(CAN_RUN_TASKS)
run = await service.start_run(
organization_id=context.org_id,
task_id=task_id,
user_id=context.user.id,
employee_id=payload.employee_id,
)
return await _run_detail(service, context.org_id, run.id)
@router.get("/runs/{run_id}", response_model=RunDetailOut)
async def get_run(
run_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> RunDetailOut:
return await _run_detail(service, context.org_id, run_id)
@router.post("/runs/{run_id}/cancel", response_model=RunDetailOut)
async def cancel_run(
run_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> RunDetailOut:
context.require(CAN_RUN_TASKS)
run = await service.cancel_run(organization_id=context.org_id, run_id=run_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.RUN_CANCEL, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="run", target_id=run.id,
ip=ip, user_agent=ua,
)
return await _run_detail(service, context.org_id, run.id)
@router.get("/approvals/pending", response_model=list[ToolCallOut])
async def list_pending_approvals(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[ToolCallOut]:
calls = await service.list_pending_approvals(context.org_id)
return [
ToolCallOut(
id=c.id, tool_key=c.tool_key, status=str(c.status),
requires_approval=c.requires_approval,
arguments_json=dict(c.arguments_json), result_json=dict(c.result_json),
created_at=c.created_at,
)
for c in calls
]
# ---------------------------------------------------------------------------
# Tool catalog & approvals
# ---------------------------------------------------------------------------
@router.get("/tools", response_model=list[ToolSpecOut])
async def list_tools(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[ToolSpecOut]:
"""The catalog of tools an employee can be granted."""
return [
ToolSpecOut(
key=spec.key, label=spec.label, description=spec.description,
requires_approval=spec.requires_approval, destructive=spec.destructive,
category=spec.category,
)
for spec in service.tool_catalog()
]
@router.put(
"/employees/{employee_id}/tools/{tool_key}", response_model=ToolPermissionOut
)
async def set_tool_permission(
employee_id: str,
tool_key: str,
payload: SetToolPermissionRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> ToolPermissionOut:
context.require(CAN_MANAGE_EMPLOYEES)
permission = await service.set_tool_permission(
organization_id=context.org_id,
employee_id=employee_id,
tool_key=tool_key,
allowed=payload.allowed,
)
return ToolPermissionOut(tool_key=permission.tool_key, allowed=permission.allowed)
@router.post("/approvals/{tool_call_id}/decide", response_model=ToolCallOut)
async def decide_approval(
tool_call_id: str,
payload: DecideApprovalRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> ToolCallOut:
context.require(CAN_RUN_TASKS)
call = await service.decide_approval(
organization_id=context.org_id,
tool_call_id=tool_call_id,
approve=payload.approve,
decided_by_user_id=context.user.id,
note=payload.note,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.APPROVAL_DECISION, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="tool_call", target_id=call.id,
metadata={"tool_key": call.tool_key, "approved": payload.approve},
ip=ip, user_agent=ua,
)
return ToolCallOut(
id=call.id, tool_key=call.tool_key, status=str(call.status),
requires_approval=call.requires_approval,
arguments_json=dict(call.arguments_json), result_json=dict(call.result_json),
created_at=call.created_at,
)
async def _run_detail(
service: WorkforceService, organization_id: str, run_id: str
) -> RunDetailOut:
run, steps, tool_calls, artifacts = await service.get_run_or_404(
organization_id=organization_id, run_id=run_id
)
base = _run_out(run)
return RunDetailOut(
**base.model_dump(),
steps=[
RunStepOut(
id=s.id, index=s.index, kind=s.kind, title=s.title,
payload_json=dict(s.payload_json), created_at=s.created_at,
)
for s in steps
],
tool_calls=[
ToolCallOut(
id=c.id, tool_key=c.tool_key, status=str(c.status),
requires_approval=c.requires_approval,
arguments_json=dict(c.arguments_json),
result_json=dict(c.result_json), created_at=c.created_at,
)
for c in tool_calls
],
artifacts=[
ArtifactOut(
id=a.id, name=a.name, kind=a.kind, content_ref=a.content_ref,
size_bytes=a.size_bytes, run_id=a.run_id, created_at=a.created_at,
)
for a in artifacts
],
)