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

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ 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,276 @@
"""Repositories for the AI Control Plane.
Platform-owner-only data. The application layer is responsible for never
serialising any of it to a customer-facing response; these repositories just
move rows.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.ai_control.models import (
AiModel,
ModelCredential,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
RoutingPolicy,
)
from app.infrastructure.db.models import (
AiModelCredentialRow,
AiModelRow,
AiProviderRow,
RateCardRow,
RoutingPolicyRow,
)
def _provider_to_domain(row: AiProviderRow) -> Provider:
return Provider(
id=row.id, name=row.name, type=ProviderType(row.type),
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
)
def _credential_to_domain(row: AiModelCredentialRow) -> ModelCredential:
return ModelCredential(
id=row.id, provider_id=row.provider_id, name=row.name,
encrypted_api_key=row.encrypted_api_key, is_active=row.is_active,
created_at=row.created_at,
)
def _model_to_domain(row: AiModelRow) -> AiModel:
return AiModel(
id=row.id, provider_id=row.provider_id, internal_name=row.internal_name,
display_name_for_admin=row.display_name_for_admin,
capabilities=list(row.capabilities or []),
input_price_per_1m=row.input_price_per_1m,
output_price_per_1m=row.output_price_per_1m,
is_active=row.is_active, created_at=row.created_at,
)
def _policy_to_domain(row: RoutingPolicyRow) -> RoutingPolicy:
return RoutingPolicy(
id=row.id, name=row.name, description=row.description,
mode=RoutingMode(row.mode),
fallback_policy_json=dict(row.fallback_policy_json or {}),
budget_limits_json=dict(row.budget_limits_json or {}),
is_default=row.is_default, is_active=row.is_active, created_at=row.created_at,
)
def _rate_card_to_domain(row: RateCardRow) -> RateCard:
return RateCard(
id=row.id, name=row.name, unit=RateUnit(row.unit),
customer_price=row.customer_price,
internal_cost_estimate=row.internal_cost_estimate,
is_active=row.is_active, created_at=row.created_at,
)
class ProviderRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self) -> list[Provider]:
stmt = select(AiProviderRow).order_by(AiProviderRow.created_at)
return [_provider_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, provider_id: str) -> Provider | None:
row = await self.session.get(AiProviderRow, provider_id)
return _provider_to_domain(row) if row else None
async def get_by_name(self, name: str) -> Provider | None:
stmt = select(AiProviderRow).where(AiProviderRow.name == name)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _provider_to_domain(row) if row else None
async def create(self, provider: Provider) -> Provider:
row = AiProviderRow(
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,
)
self.session.add(row)
await self.session.flush()
return _provider_to_domain(row)
async def set_active(self, provider_id: str, active: bool) -> None:
row = await self.session.get(AiProviderRow, provider_id)
if row:
row.is_active = active
await self.session.flush()
class ModelCredentialRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_provider(self, provider_id: str) -> list[ModelCredential]:
stmt = select(AiModelCredentialRow).where(
AiModelCredentialRow.provider_id == provider_id
)
return [_credential_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, credential_id: str) -> ModelCredential | None:
row = await self.session.get(AiModelCredentialRow, credential_id)
return _credential_to_domain(row) if row else None
async def get_active_for_provider(self, provider_id: str) -> ModelCredential | None:
stmt = (
select(AiModelCredentialRow)
.where(
AiModelCredentialRow.provider_id == provider_id,
AiModelCredentialRow.is_active.is_(True),
)
.order_by(AiModelCredentialRow.created_at.desc())
)
row = (await self.session.execute(stmt)).scalars().first()
return _credential_to_domain(row) if row else None
async def create(self, credential: ModelCredential) -> ModelCredential:
row = AiModelCredentialRow(
id=credential.id, provider_id=credential.provider_id, name=credential.name,
encrypted_api_key=credential.encrypted_api_key,
is_active=credential.is_active, created_at=credential.created_at,
)
self.session.add(row)
await self.session.flush()
return _credential_to_domain(row)
class AiModelRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self, *, provider_id: str | None = None) -> list[AiModel]:
stmt = select(AiModelRow)
if provider_id:
stmt = stmt.where(AiModelRow.provider_id == provider_id)
stmt = stmt.order_by(AiModelRow.created_at)
return [_model_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, model_id: str) -> AiModel | None:
row = await self.session.get(AiModelRow, model_id)
return _model_to_domain(row) if row else None
async def create(self, model: AiModel) -> AiModel:
row = AiModelRow(
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, created_at=model.created_at,
)
self.session.add(row)
await self.session.flush()
return _model_to_domain(row)
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
"""Choose a model for a routing mode.
Deterministic and explainable: filter by required capability, then take
the cheapest active model. A real implementation would add health and
latency signals; keeping it simple here makes the behaviour testable.
"""
required: set[str] = set()
if mode == RoutingMode.VISION:
required.add("vision")
if mode == RoutingMode.CODING:
required.add("code")
if mode == RoutingMode.REASONING:
required.add("reasoning")
candidates = [m for m in await self.list() if m.is_active]
if required:
filtered = [m for m in candidates if required.issubset(set(m.capabilities))]
if filtered:
candidates = filtered
if not candidates:
return None
return min(candidates, key=lambda m: (m.input_price_per_1m, m.output_price_per_1m))
class RoutingPolicyRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self) -> list[RoutingPolicy]:
stmt = select(RoutingPolicyRow).order_by(RoutingPolicyRow.created_at)
return [_policy_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, policy_id: str) -> RoutingPolicy | None:
row = await self.session.get(RoutingPolicyRow, policy_id)
return _policy_to_domain(row) if row else None
async def get_default(self) -> RoutingPolicy | None:
stmt = select(RoutingPolicyRow).where(RoutingPolicyRow.is_default.is_(True))
row = (await self.session.execute(stmt)).scalars().first()
return _policy_to_domain(row) if row else None
async def create(self, policy: RoutingPolicy) -> RoutingPolicy:
if policy.is_default:
await self._clear_defaults()
row = RoutingPolicyRow(
id=policy.id, name=policy.name, description=policy.description,
mode=policy.mode.value,
fallback_policy_json=policy.fallback_policy_json,
budget_limits_json=policy.budget_limits_json,
is_default=policy.is_default, is_active=policy.is_active,
created_at=policy.created_at,
)
self.session.add(row)
await self.session.flush()
return _policy_to_domain(row)
async def _clear_defaults(self) -> None:
rows = (await self.session.execute(select(RoutingPolicyRow))).scalars().all()
for row in rows:
row.is_default = False
await self.session.flush()
class RateCardRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self) -> list[RateCard]:
stmt = select(RateCardRow).order_by(RateCardRow.unit)
return [_rate_card_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def get_by_unit(self, unit: RateUnit) -> RateCard | None:
stmt = select(RateCardRow).where(RateCardRow.unit == unit.value)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _rate_card_to_domain(row) if row else None
async def upsert(self, card: RateCard) -> RateCard:
existing = await self.get_by_unit(card.unit)
if existing:
row = await self.session.get(RateCardRow, existing.id)
assert row is not None
row.customer_price = card.customer_price
row.internal_cost_estimate = card.internal_cost_estimate
row.name = card.name
row.is_active = card.is_active
await self.session.flush()
return _rate_card_to_domain(row)
return await self.create(card)
async def create(self, card: RateCard) -> RateCard:
row = RateCardRow(
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, created_at=card.created_at,
)
self.session.add(row)
await self.session.flush()
return _rate_card_to_domain(row)
@@ -0,0 +1,496 @@
"""Repositories for Audit & Observability and the Usage Ledger."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
from app.domain.ai_control.models import RateUnit
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
from app.infrastructure.db.models import (
AuditEventRow,
ErrorEventRow,
QualityMetricRow,
SpanRow,
TraceRow,
UsageEventRow,
)
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
return AuditEvent(
id=row.id,
organization_id=row.organization_id,
actor_user_id=row.actor_user_id,
action=row.action,
target_type=row.target_type,
target_id=row.target_id,
metadata_json=row.metadata_json or {},
ip_address=row.ip_address,
user_agent=row.user_agent,
created_at=row.created_at,
)
def _error_to_domain(row: ErrorEventRow) -> ErrorEvent:
return ErrorEvent(
id=row.id,
organization_id=row.organization_id,
run_id=row.run_id,
kind=row.kind,
message=row.message,
stack=row.stack,
metadata_json=row.metadata_json or {},
source=row.source,
severity=row.severity,
error_type=row.error_type,
fingerprint=row.fingerprint,
status_code=row.status_code,
method=row.method,
path=row.path,
user_id=row.user_id,
request_id=row.request_id,
ip=row.ip,
user_agent=row.user_agent,
resolved_at=row.resolved_at,
resolved_note=row.resolved_note,
created_at=row.created_at,
)
class AuditRepository:
"""Concrete ``AuditPort``. Append-only."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def record(self, *, event: AuditEvent) -> AuditEvent:
row = AuditEventRow(
id=event.id,
organization_id=event.organization_id,
actor_user_id=event.actor_user_id,
action=event.action,
target_type=event.target_type,
target_id=event.target_id,
metadata_json=event.metadata_json,
ip_address=event.ip_address,
user_agent=event.user_agent,
created_at=event.created_at,
)
self.session.add(row)
await self.session.flush()
return _audit_to_domain(row)
async def list_for_organization(
self, *, organization_id: str, limit: int = 100, offset: int = 0
) -> list[AuditEvent]:
stmt = (
select(AuditEventRow)
.where(AuditEventRow.organization_id == organization_id)
.order_by(AuditEventRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_platform(self, *, limit: int = 100, offset: int = 0) -> list[AuditEvent]:
stmt = (
select(AuditEventRow)
.order_by(AuditEventRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
class UsageLedgerRepository:
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def record(self, *, usage: UsageRecord) -> None:
total = usage.total_price or (usage.quantity * usage.unit_price)
row = UsageEventRow(
organization_id=usage.organization_id,
project_id=usage.project_id,
user_id=usage.user_id,
employee_id=usage.employee_id,
run_id=usage.run_id,
unit=usage.unit.value,
quantity=usage.quantity,
unit_price=usage.unit_price,
internal_cost_estimate=usage.internal_cost_estimate,
total_price=total,
metadata_json=usage.metadata_json,
)
self.session.add(row)
await self.session.flush()
async def breakdown(
self,
*,
organization_id: str,
since: datetime | None = None,
until: datetime | None = None,
) -> UsageBreakdown:
filters = [UsageEventRow.organization_id == organization_id]
if since is not None:
filters.append(UsageEventRow.created_at >= since)
if until is not None:
filters.append(UsageEventRow.created_at <= until)
by_unit_stmt = (
select(
UsageEventRow.unit,
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
func.coalesce(func.sum(UsageEventRow.internal_cost_estimate), 0.0),
)
.where(*filters)
.group_by(UsageEventRow.unit)
)
by_unit = [
UsageAggregate(
unit=RateUnit(unit),
quantity=float(qty),
total_price=float(price),
internal_cost_estimate=float(cost),
)
for unit, qty, price, cost in (await self.session.execute(by_unit_stmt)).all()
]
emp_stmt = (
select(
UsageEventRow.employee_id,
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
)
.where(*filters, UsageEventRow.employee_id.is_not(None))
.group_by(UsageEventRow.employee_id)
)
by_employee = {
str(emp): float(total)
for emp, total in (await self.session.execute(emp_stmt)).all()
}
proj_stmt = (
select(
UsageEventRow.project_id,
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
)
.where(*filters, UsageEventRow.project_id.is_not(None))
.group_by(UsageEventRow.project_id)
)
by_project = {
str(proj): float(total)
for proj, total in (await self.session.execute(proj_stmt)).all()
}
return UsageBreakdown(
by_unit=by_unit,
by_employee=by_employee,
by_project=by_project,
total_price=sum(a.total_price for a in by_unit),
total_cost=sum(a.internal_cost_estimate for a in by_unit),
)
async def list_events(
self, *, organization_id: str, limit: int = 100, offset: int = 0
) -> list[UsageEventRow]:
stmt = (
select(UsageEventRow)
.where(UsageEventRow.organization_id == organization_id)
.order_by(UsageEventRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return list((await self.session.execute(stmt)).scalars())
async def platform_totals(self) -> list[tuple[str, float, float]]:
"""``(organization_id, quantity, total_price)`` across every tenant.
Platform-owner console only.
"""
stmt = (
select(
UsageEventRow.organization_id,
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
)
.group_by(UsageEventRow.organization_id)
.order_by(func.sum(UsageEventRow.total_price).desc())
)
rows = (await self.session.execute(stmt)).all()
return [(str(org), float(qty), float(price)) for org, qty, price in rows]
class ObservabilityRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create_trace(self, trace: Trace) -> Trace:
self.session.add(
TraceRow(
id=trace.id, organization_id=trace.organization_id,
run_id=trace.run_id, name=trace.name, created_at=trace.created_at,
)
)
await self.session.flush()
return trace
async def add_span(self, span: Span) -> Span:
self.session.add(
SpanRow(
id=span.id, trace_id=span.trace_id, parent_span_id=span.parent_span_id,
name=span.name, kind=span.kind, attributes_json=span.attributes_json,
started_at=span.started_at, ended_at=span.ended_at,
)
)
await self.session.flush()
return span
async def record_error(self, event: ErrorEvent) -> None:
self.session.add(
ErrorEventRow(
id=event.id,
organization_id=event.organization_id,
run_id=event.run_id,
kind=event.kind,
message=event.message,
stack=event.stack,
metadata_json=event.metadata_json,
source=event.source,
severity=event.severity,
error_type=event.error_type,
fingerprint=event.fingerprint,
status_code=event.status_code,
method=event.method,
path=event.path,
user_id=event.user_id,
request_id=event.request_id,
ip=event.ip,
user_agent=event.user_agent,
resolved_at=event.resolved_at,
resolved_note=event.resolved_note,
created_at=event.created_at,
)
)
await self.session.flush()
async def record_metric(self, metric: QualityMetric) -> None:
self.session.add(
QualityMetricRow(
id=metric.id, organization_id=metric.organization_id, name=metric.name,
value=metric.value, dimensions_json=metric.dimensions_json,
created_at=metric.created_at,
)
)
await self.session.flush()
async def list_spans(self, *, trace_id: str) -> list[Span]:
stmt = select(SpanRow).where(SpanRow.trace_id == trace_id).order_by(SpanRow.started_at)
return [
Span(
id=r.id, trace_id=r.trace_id, parent_span_id=r.parent_span_id, name=r.name,
kind=r.kind, attributes_json=r.attributes_json or {},
started_at=r.started_at, ended_at=r.ended_at,
)
for r in (await self.session.execute(stmt)).scalars()
]
# -- error console --------------------------------------------------------
def _error_filters(
self,
*,
severity: str | None = None,
source: str | None = None,
status: str | None = None,
kind: str | None = None,
organization_id: str | None = None,
fingerprint: str | None = None,
since: datetime | None = None,
search: str | None = None,
) -> list:
"""Build the WHERE clauses shared by the list and count queries."""
clauses: list = []
if severity:
clauses.append(ErrorEventRow.severity == severity)
if source:
clauses.append(ErrorEventRow.source == source)
if status == "open":
clauses.append(ErrorEventRow.resolved_at.is_(None))
elif status == "resolved":
clauses.append(ErrorEventRow.resolved_at.is_not(None))
if kind:
clauses.append(ErrorEventRow.kind == kind)
if organization_id:
clauses.append(ErrorEventRow.organization_id == organization_id)
if fingerprint:
clauses.append(ErrorEventRow.fingerprint == fingerprint)
if since is not None:
clauses.append(ErrorEventRow.created_at >= since)
if search:
clauses.append(ErrorEventRow.message.ilike(f"%{search}%"))
return clauses
async def list_errors(
self,
*,
limit: int = 50,
offset: int = 0,
**filters,
) -> list[ErrorEvent]:
stmt = (
select(ErrorEventRow)
.where(*self._error_filters(**filters))
# Newest first: the failure someone is reacting to is almost always
# the most recent one.
.order_by(ErrorEventRow.created_at.desc())
.limit(limit)
.offset(offset)
)
return [_error_to_domain(row) for row in (await self.session.execute(stmt)).scalars()]
async def count_errors(self, **filters) -> int:
stmt = select(func.count()).select_from(ErrorEventRow).where(
*self._error_filters(**filters)
)
return int((await self.session.execute(stmt)).scalar_one())
async def get_error(self, error_id: str) -> ErrorEvent | None:
row = (
await self.session.execute(
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
)
).scalar_one_or_none()
return _error_to_domain(row) if row is not None else None
async def list_error_groups(
self,
*,
limit: int = 50,
since: datetime | None = None,
status: str | None = None,
severity: str | None = None,
source: str | None = None,
organization_id: str | None = None,
) -> list[dict]:
"""One row per distinct bug, newest activity first.
This is the view that makes the console usable: a bug that fired 4 000
times is one line with a count, not 4 000 lines. ``open_count`` is what
tells you whether anyone has actually dealt with it.
"""
clauses: list = [ErrorEventRow.fingerprint != ""]
if since is not None:
clauses.append(ErrorEventRow.created_at >= since)
if severity:
clauses.append(ErrorEventRow.severity == severity)
if source:
clauses.append(ErrorEventRow.source == source)
if organization_id:
clauses.append(ErrorEventRow.organization_id == organization_id)
open_count = func.sum(
case((ErrorEventRow.resolved_at.is_(None), 1), else_=0)
).label("open_count")
stmt = (
select(
ErrorEventRow.fingerprint,
func.count().label("occurrences"),
open_count,
func.min(ErrorEventRow.created_at).label("first_seen"),
func.max(ErrorEventRow.created_at).label("last_seen"),
func.max(ErrorEventRow.kind).label("kind"),
func.max(ErrorEventRow.error_type).label("error_type"),
func.max(ErrorEventRow.severity).label("severity"),
func.max(ErrorEventRow.source).label("source"),
func.max(ErrorEventRow.path).label("path"),
func.max(ErrorEventRow.message).label("sample_message"),
)
.where(*clauses)
.group_by(ErrorEventRow.fingerprint)
.order_by(func.max(ErrorEventRow.created_at).desc())
.limit(limit)
)
rows = (await self.session.execute(stmt)).all()
groups = [
{
"fingerprint": row.fingerprint,
"occurrences": int(row.occurrences),
"open_count": int(row.open_count or 0),
"first_seen": row.first_seen,
"last_seen": row.last_seen,
"kind": row.kind or "",
"error_type": row.error_type or "",
"severity": row.severity or "",
"source": row.source or "",
"path": row.path or "",
"sample_message": row.sample_message or "",
}
for row in rows
]
if status == "open":
return [g for g in groups if g["open_count"] > 0]
if status == "resolved":
return [g for g in groups if g["open_count"] == 0]
return groups
async def resolve_error(
self, error_id: str, *, note: str, resolved_at: datetime
) -> ErrorEvent | None:
row = (
await self.session.execute(
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
)
).scalar_one_or_none()
if row is None:
return None
row.resolved_at = resolved_at
row.resolved_note = note
await self.session.flush()
return _error_to_domain(row)
async def resolve_error_group(
self, fingerprint: str, *, note: str, resolved_at: datetime
) -> int:
"""Resolve every open occurrence of one bug. Returns how many changed."""
stmt = select(ErrorEventRow).where(
ErrorEventRow.fingerprint == fingerprint,
ErrorEventRow.resolved_at.is_(None),
)
rows = list((await self.session.execute(stmt)).scalars())
for row in rows:
row.resolved_at = resolved_at
row.resolved_note = note
await self.session.flush()
return len(rows)
async def reopen_error_group(self, fingerprint: str) -> int:
"""Undo a resolution — for when the bug comes back."""
stmt = select(ErrorEventRow).where(
ErrorEventRow.fingerprint == fingerprint,
ErrorEventRow.resolved_at.is_not(None),
)
rows = list((await self.session.execute(stmt)).scalars())
for row in rows:
row.resolved_at = None
row.resolved_note = ""
await self.session.flush()
return len(rows)
async def count_open_by_severity(self) -> dict[str, int]:
"""Unresolved counts per severity, for the console's header tiles."""
stmt = (
select(ErrorEventRow.severity, func.count())
.where(ErrorEventRow.resolved_at.is_(None))
.group_by(ErrorEventRow.severity)
)
return {
str(severity): int(count)
for severity, count in (await self.session.execute(stmt)).all()
}
@@ -0,0 +1,212 @@
"""Repositories for Billing: plans, subscriptions, wallets, invoices, payments.
The usage ledger lives in ``repositories/audit.py`` because it is shared with the
observability layer; billing reads it but never writes to it directly.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.billing.models import (
CreditWallet,
Invoice,
InvoiceStatus,
Payment,
PaymentStatus,
Plan,
Subscription,
SubscriptionStatus,
)
from app.infrastructure.db.models import (
CreditWalletRow,
InvoiceRow,
PaymentRow,
PlanRow,
SubscriptionRow,
)
def _plan(row: PlanRow) -> Plan:
return Plan(
id=row.id, name=row.name, monthly_price=row.monthly_price,
included_credit=row.included_credit, features_json=dict(row.features_json or {}),
is_active=row.is_active, created_at=row.created_at,
)
def _subscription(row: SubscriptionRow) -> Subscription:
return Subscription(
id=row.id, organization_id=row.organization_id, plan_id=row.plan_id,
status=SubscriptionStatus(row.status),
external_subscription_id=row.external_subscription_id,
current_period_end=row.current_period_end, created_at=row.created_at,
)
def _wallet(row: CreditWalletRow) -> CreditWallet:
return CreditWallet(
id=row.id, organization_id=row.organization_id, balance=row.balance,
currency=row.currency, low_balance_threshold=row.low_balance_threshold,
updated_at=row.updated_at,
)
def _invoice(row: InvoiceRow) -> Invoice:
return Invoice(
id=row.id, organization_id=row.organization_id, amount=row.amount,
currency=row.currency, status=InvoiceStatus(row.status),
external_invoice_id=row.external_invoice_id, period_start=row.period_start,
period_end=row.period_end, created_at=row.created_at,
)
def _payment(row: PaymentRow) -> Payment:
return Payment(
id=row.id, organization_id=row.organization_id, amount=row.amount,
status=PaymentStatus(row.status), provider=row.provider,
external_payment_id=row.external_payment_id, invoice_id=row.invoice_id,
created_at=row.created_at,
)
class PlanRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self) -> list[Plan]:
stmt = select(PlanRow).order_by(PlanRow.monthly_price)
return [_plan(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, plan_id: str) -> Plan | None:
row = await self.session.get(PlanRow, plan_id)
return _plan(row) if row else None
class SubscriptionRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_org(self, organization_id: str) -> Subscription | None:
stmt = select(SubscriptionRow).where(
SubscriptionRow.organization_id == organization_id
)
row = (await self.session.execute(stmt)).scalars().first()
return _subscription(row) if row else None
async def upsert(self, subscription: Subscription) -> Subscription:
existing = await self.get_for_org(subscription.organization_id)
if existing:
row = await self.session.get(SubscriptionRow, existing.id)
assert row is not None
row.plan_id = subscription.plan_id
row.status = str(subscription.status)
row.external_subscription_id = subscription.external_subscription_id
row.current_period_end = subscription.current_period_end
await self.session.flush()
return _subscription(row)
row = SubscriptionRow(
id=subscription.id, organization_id=subscription.organization_id,
plan_id=subscription.plan_id, status=str(subscription.status),
external_subscription_id=subscription.external_subscription_id,
current_period_end=subscription.current_period_end,
created_at=subscription.created_at,
)
self.session.add(row)
await self.session.flush()
return _subscription(row)
class CreditWalletRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_org(self, organization_id: str) -> CreditWallet | None:
stmt = select(CreditWalletRow).where(
CreditWalletRow.organization_id == organization_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _wallet(row) if row else None
async def ensure(self, organization_id: str) -> CreditWallet:
"""Return the org's wallet, creating a zero-balance one if absent."""
existing = await self.get_for_org(organization_id)
if existing:
return existing
row = CreditWalletRow(organization_id=organization_id, balance=0.0, currency="USD")
self.session.add(row)
await self.session.flush()
return _wallet(row)
async def adjust(self, *, organization_id: str, delta: float) -> CreditWallet:
"""Add (or subtract) credit. Callers must validate the delta's sign."""
wallet = await self.ensure(organization_id)
row = await self.session.get(CreditWalletRow, wallet.id)
assert row is not None
row.balance = float(row.balance) + float(delta)
await self.session.flush()
return _wallet(row)
class InvoiceRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_org(self, organization_id: str) -> list[Invoice]:
stmt = (
select(InvoiceRow)
.where(InvoiceRow.organization_id == organization_id)
.order_by(InvoiceRow.created_at.desc())
)
return [_invoice(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, invoice: Invoice) -> Invoice:
row = InvoiceRow(
id=invoice.id, organization_id=invoice.organization_id,
amount=invoice.amount, currency=invoice.currency,
status=str(invoice.status),
external_invoice_id=invoice.external_invoice_id,
period_start=invoice.period_start, period_end=invoice.period_end,
created_at=invoice.created_at,
)
self.session.add(row)
await self.session.flush()
return _invoice(row)
class PaymentRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_org(self, organization_id: str) -> list[Payment]:
stmt = (
select(PaymentRow)
.where(PaymentRow.organization_id == organization_id)
.order_by(PaymentRow.created_at.desc())
)
return [_payment(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, payment: Payment) -> Payment:
row = PaymentRow(
id=payment.id, organization_id=payment.organization_id,
amount=payment.amount, status=str(payment.status),
provider=payment.provider, external_payment_id=payment.external_payment_id,
invoice_id=payment.invoice_id, created_at=payment.created_at,
)
self.session.add(row)
await self.session.flush()
return _payment(row)
async def set_status(
self, payment_id: str, status: PaymentStatus, *, external_id: str | None = None
) -> Payment | None:
row = await self.session.get(PaymentRow, payment_id)
if row is None:
return None
row.status = str(status)
if external_id is not None:
row.external_payment_id = external_id
await self.session.flush()
return _payment(row)
@@ -0,0 +1,269 @@
"""Repositories for Identity & Access.
Each repository maps between the ORM rows and the pure domain dataclasses, so
the domain layer never sees SQLAlchemy.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.identity.models import (
Invite,
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.infrastructure.db.models import (
InviteRow,
MembershipRow,
OrganizationRow,
UserRow,
)
def _user_to_domain(row: UserRow) -> User:
return User(
id=row.id,
email=row.email,
password_hash=row.password_hash,
name=row.name,
avatar_url=row.avatar_url,
is_platform_owner=row.is_platform_owner,
is_active=row.is_active,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _org_to_domain(row: OrganizationRow) -> Organization:
return Organization(
id=row.id, name=row.name, slug=row.slug,
owner_user_id=row.owner_user_id, created_at=row.created_at,
)
def _membership_to_domain(row: MembershipRow) -> Membership:
return Membership(
id=row.id,
organization_id=row.organization_id,
user_id=row.user_id,
role=Role(row.role),
status=MembershipStatus(row.status),
created_at=row.created_at,
)
def _invite_to_domain(row: InviteRow) -> Invite:
return Invite(
id=row.id,
organization_id=row.organization_id,
email=row.email,
role=Role(row.role),
token_hash=row.token_hash,
invited_by_user_id=row.invited_by_user_id,
expires_at=row.expires_at,
accepted_at=row.accepted_at,
created_at=row.created_at,
)
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, user_id: str) -> User | None:
row = await self.session.get(UserRow, user_id)
return _user_to_domain(row) if row else None
async def get_by_email(self, email: str) -> User | None:
stmt = select(UserRow).where(func.lower(UserRow.email) == email.strip().lower())
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _user_to_domain(row) if row else None
async def create(self, user: User) -> User:
row = UserRow(
id=user.id,
email=user.normalized_email(),
password_hash=user.password_hash,
name=user.name,
avatar_url=user.avatar_url,
is_platform_owner=user.is_platform_owner,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at,
)
self.session.add(row)
await self.session.flush()
return _user_to_domain(row)
async def update_password(self, user_id: str, password_hash: str) -> None:
row = await self.session.get(UserRow, user_id)
if row:
row.password_hash = password_hash
await self.session.flush()
async def count(self) -> int:
return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one())
class OrganizationRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, org_id: str) -> Organization | None:
row = await self.session.get(OrganizationRow, org_id)
return _org_to_domain(row) if row else None
async def get_by_slug(self, slug: str) -> Organization | None:
stmt = select(OrganizationRow).where(OrganizationRow.slug == slug)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _org_to_domain(row) if row else None
async def list_all_slugs(self) -> set[str]:
rows = (await self.session.execute(select(OrganizationRow.slug))).scalars().all()
return set(rows)
async def create(self, org: Organization) -> Organization:
row = OrganizationRow(
id=org.id, name=org.name, slug=org.slug,
owner_user_id=org.owner_user_id, created_at=org.created_at,
)
self.session.add(row)
await self.session.flush()
return _org_to_domain(row)
async def list_for_user(self, user_id: str) -> list[tuple[Organization, Role]]:
stmt = (
select(OrganizationRow, MembershipRow.role)
.join(MembershipRow, MembershipRow.organization_id == OrganizationRow.id)
.where(MembershipRow.user_id == user_id, MembershipRow.status == "active")
.order_by(OrganizationRow.created_at)
)
rows = (await self.session.execute(stmt)).all()
return [(_org_to_domain(o), Role(r)) for o, r in rows]
async def list_all(self) -> list[Organization]:
"""Every organization. Platform-owner console only — never expose this
through a tenant-scoped endpoint."""
stmt = select(OrganizationRow).order_by(OrganizationRow.created_at)
return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def delete(self, org_id: str) -> None:
row = await self.session.get(OrganizationRow, org_id)
if row:
await self.session.delete(row)
await self.session.flush()
async def count(self) -> int:
return int(
(await self.session.execute(select(func.count(OrganizationRow.id)))).scalar_one()
)
class MembershipRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, org_id: str, user_id: str) -> Membership | None:
stmt = select(MembershipRow).where(
MembershipRow.organization_id == org_id, MembershipRow.user_id == user_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _membership_to_domain(row) if row else None
async def get_by_id(self, membership_id: str) -> Membership | None:
row = await self.session.get(MembershipRow, membership_id)
return _membership_to_domain(row) if row else None
async def list_for_org(self, org_id: str) -> list[Membership]:
stmt = (
select(MembershipRow)
.where(MembershipRow.organization_id == org_id)
.order_by(MembershipRow.created_at)
)
return [_membership_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def add(self, membership: Membership) -> Membership:
row = MembershipRow(
id=membership.id,
organization_id=membership.organization_id,
user_id=membership.user_id,
role=membership.role.value,
status=membership.status.value,
created_at=membership.created_at,
)
self.session.add(row)
await self.session.flush()
return _membership_to_domain(row)
async def set_role(self, membership_id: str, role: Role) -> None:
row = await self.session.get(MembershipRow, membership_id)
if row:
row.role = role.value
await self.session.flush()
async def set_status(self, membership_id: str, status: MembershipStatus) -> None:
row = await self.session.get(MembershipRow, membership_id)
if row:
row.status = status.value
await self.session.flush()
async def remove(self, membership_id: str) -> None:
row = await self.session.get(MembershipRow, membership_id)
if row:
await self.session.delete(row)
await self.session.flush()
async def count_owners(self, org_id: str) -> int:
stmt = select(func.count(MembershipRow.id)).where(
MembershipRow.organization_id == org_id,
MembershipRow.role == Role.OWNER.value,
MembershipRow.status == MembershipStatus.ACTIVE.value,
)
return int((await self.session.execute(stmt)).scalar_one())
class InviteRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _invite_to_domain(row) if row else None
async def list_pending(self, org_id: str) -> list[Invite]:
stmt = (
select(InviteRow)
.where(InviteRow.organization_id == org_id, InviteRow.accepted_at.is_(None))
.order_by(InviteRow.created_at.desc())
)
return [_invite_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, invite: Invite) -> Invite:
row = InviteRow(
id=invite.id,
organization_id=invite.organization_id,
email=invite.email.strip().lower(),
role=invite.role.value,
token_hash=invite.token_hash,
invited_by_user_id=invite.invited_by_user_id,
expires_at=invite.expires_at,
accepted_at=invite.accepted_at,
created_at=invite.created_at,
)
self.session.add(row)
await self.session.flush()
return _invite_to_domain(row)
async def mark_accepted(self, invite_id: str, when: datetime) -> None:
row = await self.session.get(InviteRow, invite_id)
if row:
row.accepted_at = when
await self.session.flush()
@@ -0,0 +1,285 @@
"""Repositories for the Knowledge Base.
Retrieval is tenant-scoped by construction: every search joins
``document_chunks -> documents -> knowledge_sources`` and filters on
``knowledge_sources.organization_id``. There is no search entry point that takes
a chunk id directly, so a caller cannot reach across tenants.
"""
from __future__ import annotations
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.knowledge.models import (
Document,
DocumentChunk,
EmbeddingJob,
EmbeddingJobStatus,
KnowledgeSource,
RetrievalPolicy,
SourceKind,
)
from app.infrastructure.db.models import (
DocumentChunkRow,
DocumentRow,
EmbeddingJobRow,
KnowledgeSourceRow,
RetrievalPolicyRow,
)
def _source(row: KnowledgeSourceRow) -> KnowledgeSource:
return KnowledgeSource(
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
name=row.name, kind=SourceKind(row.kind), uri=row.uri, created_at=row.created_at,
)
def _document(row: DocumentRow) -> Document:
return Document(
id=row.id, source_id=row.source_id, title=row.title, content=row.content,
created_at=row.created_at,
)
class KnowledgeSourceRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, source_id: str) -> KnowledgeSource | None:
row = await self.session.get(KnowledgeSourceRow, source_id)
return _source(row) if row else None
async def get_for_org(self, organization_id: str, source_id: str) -> KnowledgeSource | None:
stmt = select(KnowledgeSourceRow).where(
KnowledgeSourceRow.id == source_id,
KnowledgeSourceRow.organization_id == organization_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _source(row) if row else None
async def list_for_org(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[KnowledgeSource]:
stmt = (
select(KnowledgeSourceRow)
.where(KnowledgeSourceRow.organization_id == organization_id)
.order_by(KnowledgeSourceRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_source(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, source: KnowledgeSource) -> KnowledgeSource:
row = KnowledgeSourceRow(
id=source.id, organization_id=source.organization_id,
project_id=source.project_id, name=source.name, kind=str(source.kind),
uri=source.uri, created_at=source.created_at,
)
self.session.add(row)
await self.session.flush()
return _source(row)
async def delete(self, source_id: str) -> None:
row = await self.session.get(KnowledgeSourceRow, source_id)
if row:
await self.session.delete(row)
await self.session.flush()
class DocumentRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_source(self, source_id: str) -> list[Document]:
stmt = (
select(DocumentRow)
.where(DocumentRow.source_id == source_id)
.order_by(DocumentRow.created_at)
)
return [_document(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, document: Document) -> Document:
row = DocumentRow(
id=document.id, source_id=document.source_id, title=document.title,
content=document.content, created_at=document.created_at,
)
self.session.add(row)
await self.session.flush()
return _document(row)
class DocumentChunkRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create_many(self, chunks: list[DocumentChunk]) -> int:
for chunk in chunks:
self.session.add(
DocumentChunkRow(
id=chunk.id, document_id=chunk.document_id, index=chunk.index,
content=chunk.content, embedding_json=list(chunk.embedding_json),
)
)
await self.session.flush()
return len(chunks)
async def count_for_source(self, source_id: str) -> int:
stmt = (
select(func.count())
.select_from(DocumentChunkRow)
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
.where(DocumentRow.source_id == source_id)
)
return int((await self.session.execute(stmt)).scalar_one())
async def count_for_sources(self, source_ids: list[str]) -> dict[str, int]:
"""Chunk counts for many sources in one grouped query.
Listing sources needs a count per row; doing that one source at a time
is an N+1. ``GROUP BY`` collapses it into a single round trip, and
sources with no chunks are simply absent from the result.
"""
if not source_ids:
return {}
stmt = (
select(DocumentRow.source_id, func.count(DocumentChunkRow.id))
.select_from(DocumentChunkRow)
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
.where(DocumentRow.source_id.in_(source_ids))
.group_by(DocumentRow.source_id)
)
rows = (await self.session.execute(stmt)).all()
return {str(source_id): int(count) for source_id, count in rows}
async def keyword_search(
self,
*,
organization_id: str,
query: str,
project_id: str | None = None,
limit: int = 5,
) -> list[dict]:
"""MVP retrieval: case-insensitive substring match over chunk text.
A real deployment swaps this for a vector search through
``VectorStorePort``; the tenant scoping stays identical.
"""
needle = f"%{query.strip().lower()}%"
if not query.strip():
return []
stmt = (
select(
DocumentChunkRow.id,
DocumentChunkRow.content,
DocumentChunkRow.index,
DocumentRow.title,
KnowledgeSourceRow.id,
KnowledgeSourceRow.name,
KnowledgeSourceRow.project_id,
)
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
.join(KnowledgeSourceRow, KnowledgeSourceRow.id == DocumentRow.source_id)
.where(
KnowledgeSourceRow.organization_id == organization_id,
func.lower(DocumentChunkRow.content).like(needle),
)
.order_by(DocumentChunkRow.document_id, DocumentChunkRow.index)
.limit(min(limit, 50))
)
if project_id is not None:
# An org-wide source (project_id is NULL) is visible to every project.
stmt = stmt.where(
or_(
KnowledgeSourceRow.project_id.is_(None),
KnowledgeSourceRow.project_id == project_id,
)
)
rows = (await self.session.execute(stmt)).all()
return [
{
"chunk_id": chunk_id,
"content": content,
"index": index,
"document_title": title,
"source_id": source_id,
"source_name": source_name,
"project_id": source_project_id,
}
for chunk_id, content, index, title, source_id, source_name, source_project_id in rows
]
class EmbeddingJobRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create(self, job: EmbeddingJob) -> EmbeddingJob:
row = EmbeddingJobRow(
id=job.id, source_id=job.source_id, status=str(job.status),
error=job.error, created_at=job.created_at,
)
self.session.add(row)
await self.session.flush()
return EmbeddingJob(
id=row.id, source_id=row.source_id,
status=EmbeddingJobStatus(row.status), error=row.error,
created_at=row.created_at,
)
async def latest_for_source(self, source_id: str) -> EmbeddingJob | None:
stmt = (
select(EmbeddingJobRow)
.where(EmbeddingJobRow.source_id == source_id)
.order_by(EmbeddingJobRow.created_at.desc())
)
row = (await self.session.execute(stmt)).scalars().first()
if row is None:
return None
return EmbeddingJob(
id=row.id, source_id=row.source_id,
status=EmbeddingJobStatus(row.status), error=row.error,
created_at=row.created_at,
)
async def set_status(
self, job_id: str, status: EmbeddingJobStatus, *, error: str | None = None
) -> None:
row = await self.session.get(EmbeddingJobRow, job_id)
if row:
row.status = str(status)
if error is not None:
row.error = error
await self.session.flush()
class RetrievalPolicyRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_org(self, organization_id: str) -> list[RetrievalPolicy]:
stmt = select(RetrievalPolicyRow).where(
RetrievalPolicyRow.organization_id == organization_id
)
return [
RetrievalPolicy(
id=r.id, organization_id=r.organization_id, name=r.name,
scope_json=dict(r.scope_json or {}), created_at=r.created_at,
)
for r in (await self.session.execute(stmt)).scalars()
]
async def create(self, policy: RetrievalPolicy) -> RetrievalPolicy:
row = RetrievalPolicyRow(
id=policy.id, organization_id=policy.organization_id, name=policy.name,
scope_json=dict(policy.scope_json), created_at=policy.created_at,
)
self.session.add(row)
await self.session.flush()
return RetrievalPolicy(
id=row.id, organization_id=row.organization_id, name=row.name,
scope_json=dict(row.scope_json or {}), created_at=row.created_at,
)
@@ -0,0 +1,253 @@
"""Repositories for Projects, their Git repositories, credentials and preview
domains."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.projects.models import (
Project,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.db.models import (
PreviewDomainRow,
PreviewPortRow,
ProjectGitCredentialRow,
ProjectRepositoryRow,
ProjectRow,
)
def _project_to_domain(row: ProjectRow) -> Project:
return Project(
id=row.id, organization_id=row.organization_id, name=row.name, slug=row.slug,
description=row.description, created_by_user_id=row.created_by_user_id,
default_branch=row.default_branch, created_at=row.created_at,
)
def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
return ProjectGitRepo(
id=row.id, project_id=row.project_id, provider=row.provider, owner=row.owner,
repo_name=row.repo_name, clone_url=row.clone_url, web_url=row.web_url,
default_branch=row.default_branch, created_at=row.created_at,
)
class ProjectGitRepoRepository:
"""Persistence for the Git repository attached to a project."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> ProjectGitRepo | None:
stmt = select(ProjectRepositoryRow).where(
ProjectRepositoryRow.project_id == project_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _repo_to_domain(row) if row else None
async def upsert(self, repo: ProjectGitRepo) -> ProjectGitRepo:
existing = await self.get_for_project(repo.project_id)
if existing:
row = await self.session.get(ProjectRepositoryRow, existing.id)
assert row is not None
row.provider = repo.provider
row.owner = repo.owner
row.repo_name = repo.repo_name
row.clone_url = repo.clone_url
row.web_url = repo.web_url
row.default_branch = repo.default_branch
await self.session.flush()
return _repo_to_domain(row)
row = ProjectRepositoryRow(
id=repo.id, project_id=repo.project_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,
created_at=repo.created_at,
)
self.session.add(row)
await self.session.flush()
return _repo_to_domain(row)
class ProjectGitCredentialRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> ProjectGitCredential | None:
stmt = select(ProjectGitCredentialRow).where(
ProjectGitCredentialRow.project_id == project_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
return None
return ProjectGitCredential(
id=row.id, project_id=row.project_id, username=row.username,
encrypted_password=row.encrypted_password, created_at=row.created_at,
)
async def upsert(self, credential: ProjectGitCredential) -> ProjectGitCredential:
existing = await self.get_for_project(credential.project_id)
if existing:
row = await self.session.get(ProjectGitCredentialRow, existing.id)
assert row is not None
row.username = credential.username
row.encrypted_password = credential.encrypted_password
await self.session.flush()
return ProjectGitCredential(
id=row.id, project_id=row.project_id, username=row.username,
encrypted_password=row.encrypted_password, created_at=row.created_at,
)
row = ProjectGitCredentialRow(
id=credential.id, project_id=credential.project_id,
username=credential.username,
encrypted_password=credential.encrypted_password,
created_at=credential.created_at,
)
self.session.add(row)
await self.session.flush()
return credential
class ProjectRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, project_id: str) -> Project | None:
row = await self.session.get(ProjectRow, project_id)
return _project_to_domain(row) if row else None
async def list_for_org(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Project]:
stmt = (
select(ProjectRow)
.where(ProjectRow.organization_id == organization_id)
.order_by(ProjectRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_project_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_slugs(self, organization_id: str) -> set[str]:
stmt = select(ProjectRow.slug).where(ProjectRow.organization_id == organization_id)
return set((await self.session.execute(stmt)).scalars().all())
async def create(self, project: Project) -> Project:
row = ProjectRow(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
created_by_user_id=project.created_by_user_id,
default_branch=project.default_branch, created_at=project.created_at,
)
self.session.add(row)
await self.session.flush()
return _project_to_domain(row)
async def delete(self, project_id: str) -> None:
row = await self.session.get(ProjectRow, project_id)
if row:
await self.session.delete(row)
await self.session.flush()
class PreviewDomainRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
# -- ports ---------------------------------------------------------------
async def get_port(self, project_id: str, service_name: str) -> int | None:
stmt = select(PreviewPortRow.port).where(
PreviewPortRow.project_id == project_id,
PreviewPortRow.service_name == service_name,
)
return (await self.session.execute(stmt)).scalar_one_or_none()
async def list_used_ports(self, *, start: int, end: int) -> set[int]:
stmt = select(PreviewPortRow.port).where(
PreviewPortRow.port.between(start, end)
)
return set((await self.session.execute(stmt)).scalars().all())
async def add_port(
self, *, organization_id: str, project_id: str, service_name: str, port: int
) -> None:
self.session.add(
PreviewPortRow(
organization_id=organization_id, project_id=project_id,
service_name=service_name, port=port, status="active",
)
)
await self.session.flush()
# -- domains -------------------------------------------------------------
async def get_by_host(self, host: str) -> dict | None:
stmt = select(PreviewDomainRow).where(PreviewDomainRow.host == host)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row) if row else None
async def get(self, domain_id: str) -> dict | None:
row = await self.session.get(PreviewDomainRow, domain_id)
return self._row_to_dict(row) if row else None
async def list_for_project(self, project_id: str) -> list[dict]:
stmt = (
select(PreviewDomainRow)
.where(PreviewDomainRow.project_id == project_id)
.order_by(PreviewDomainRow.created_at)
)
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
async def count_for_project(self, project_id: str) -> int:
return len(await self.list_for_project(project_id))
async def create(
self,
*,
organization_id: str,
project_id: str,
host: str,
port: int,
created_by_user_id: str | None,
) -> dict:
row = PreviewDomainRow(
organization_id=organization_id, project_id=project_id, host=host,
port=port, status="active", protected=False,
created_by_user_id=created_by_user_id,
)
self.session.add(row)
await self.session.flush()
return self._row_to_dict(row)
async def set_status(self, domain_id: str, status: str) -> None:
row = await self.session.get(PreviewDomainRow, domain_id)
if row:
row.status = status
await self.session.flush()
async def delete(self, domain_id: str) -> None:
row = await self.session.get(PreviewDomainRow, domain_id)
if row:
await self.session.delete(row)
await self.session.flush()
@staticmethod
def _row_to_dict(row: PreviewDomainRow) -> dict:
return {
"id": row.id,
"organization_id": row.organization_id,
"project_id": row.project_id,
"host": row.host,
"port": row.port,
"status": row.status,
"protected": row.protected,
"created_by_user_id": row.created_by_user_id,
"created_at": row.created_at,
}
@@ -0,0 +1,362 @@
"""Repositories for the Sandbox Gateway.
Every tenant-scoped read joins back to ``workspaces.organization_id`` so a
workspace or session id from another tenant resolves to ``None``.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.sandbox.models import (
CommandRun,
NetworkPolicy,
PreviewUrl,
SandboxProviderKind,
SandboxResourceLimit,
SandboxSession,
SandboxTemplate,
SessionStatus,
Snapshot,
Workspace,
WorkspaceStatus,
)
from app.infrastructure.db.models import (
CommandRunRow,
PreviewUrlRow,
SandboxResourceLimitRow,
SandboxSessionRow,
SandboxTemplateRow,
SnapshotRow,
WorkspaceRow,
)
def _template(row: SandboxTemplateRow) -> SandboxTemplate:
return SandboxTemplate(
id=row.id, name=row.name, provider=row.provider, image=row.image,
description=row.description, default_cpu=row.default_cpu,
default_memory_mb=row.default_memory_mb, default_disk_mb=row.default_disk_mb,
is_system=row.is_system, created_at=row.created_at,
)
def _workspace(row: WorkspaceRow) -> Workspace:
return Workspace(
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
template_id=row.template_id, provider=row.provider,
external_workspace_id=row.external_workspace_id,
status=WorkspaceStatus(row.status), created_at=row.created_at,
)
def _session(row: SandboxSessionRow) -> SandboxSession:
return SandboxSession(
id=row.id, workspace_id=row.workspace_id,
external_session_id=row.external_session_id,
status=SessionStatus(row.status), started_at=row.started_at,
stopped_at=row.stopped_at, last_active_at=row.last_active_at,
)
class SandboxTemplateRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self) -> list[SandboxTemplate]:
stmt = select(SandboxTemplateRow).order_by(SandboxTemplateRow.name)
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, template_id: str) -> SandboxTemplate | None:
row = await self.session.get(SandboxTemplateRow, template_id)
return _template(row) if row else None
async def get_by_name(self, name: str) -> SandboxTemplate | None:
stmt = select(SandboxTemplateRow).where(SandboxTemplateRow.name == name)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _template(row) if row else None
async def create(self, template: SandboxTemplate) -> SandboxTemplate:
row = SandboxTemplateRow(
id=template.id, name=template.name, provider=str(template.provider),
image=template.image, description=template.description,
default_cpu=template.default_cpu,
default_memory_mb=template.default_memory_mb,
default_disk_mb=template.default_disk_mb, is_system=template.is_system,
created_at=template.created_at,
)
self.session.add(row)
await self.session.flush()
return _template(row)
class WorkspaceRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, workspace_id: str) -> Workspace | None:
"""Unscoped lookup. Platform components only — tenant paths use
:meth:`get_for_org` so a foreign id resolves to ``None``."""
row = await self.session.get(WorkspaceRow, workspace_id)
return _workspace(row) if row else None
async def get_for_org(self, organization_id: str, workspace_id: str) -> Workspace | None:
stmt = select(WorkspaceRow).where(
WorkspaceRow.id == workspace_id,
WorkspaceRow.organization_id == organization_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _workspace(row) if row else None
async def list_for_project(
self, project_id: str, *, limit: int = 100, offset: int = 0
) -> list[Workspace]:
stmt = (
select(WorkspaceRow)
.where(WorkspaceRow.project_id == project_id)
.order_by(WorkspaceRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_workspace(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, workspace: Workspace) -> Workspace:
row = WorkspaceRow(
id=workspace.id, organization_id=workspace.organization_id,
project_id=workspace.project_id, template_id=workspace.template_id,
provider=str(workspace.provider),
external_workspace_id=workspace.external_workspace_id,
status=str(workspace.status), created_at=workspace.created_at,
)
self.session.add(row)
await self.session.flush()
return _workspace(row)
async def set_status(self, workspace_id: str, status: WorkspaceStatus) -> None:
row = await self.session.get(WorkspaceRow, workspace_id)
if row:
row.status = str(status)
await self.session.flush()
class SandboxSessionRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, session_id: str) -> SandboxSession | None:
row = await self.session.get(SandboxSessionRow, session_id)
return _session(row) if row else None
async def get_for_org(self, organization_id: str, session_id: str) -> SandboxSession | None:
"""Resolve a session through its workspace, which carries the tenant."""
stmt = (
select(SandboxSessionRow)
.join(WorkspaceRow, WorkspaceRow.id == SandboxSessionRow.workspace_id)
.where(
SandboxSessionRow.id == session_id,
WorkspaceRow.organization_id == organization_id,
)
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _session(row) if row else None
async def list_for_workspace(self, workspace_id: str) -> list[SandboxSession]:
stmt = (
select(SandboxSessionRow)
.where(SandboxSessionRow.workspace_id == workspace_id)
.order_by(SandboxSessionRow.started_at.desc())
)
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_stale_active(
self, *, idle_before: datetime, limit: int = 200
) -> list[SandboxSession]:
"""Active sessions untouched since ``idle_before``.
The gateway reaper's query. Only ``starting``/``running``/``paused``
count as active — a stopped session is already gone and must not be
stopped twice.
"""
stmt = (
select(SandboxSessionRow)
.where(
SandboxSessionRow.status.in_(
[
str(SessionStatus.STARTING),
str(SessionStatus.RUNNING),
str(SessionStatus.PAUSED),
]
),
SandboxSessionRow.last_active_at < idle_before,
)
.order_by(SandboxSessionRow.last_active_at.asc())
.limit(min(limit, 500))
)
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
row = SandboxSessionRow(
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
external_session_id=sandbox_session.external_session_id,
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
stopped_at=sandbox_session.stopped_at,
last_active_at=sandbox_session.last_active_at,
)
self.session.add(row)
await self.session.flush()
return _session(row)
async def set_status(
self, session_id: str, status: SessionStatus, *, stopped: bool = False
) -> None:
row = await self.session.get(SandboxSessionRow, session_id)
if row:
row.status = str(status)
row.last_active_at = utcnow()
if stopped:
row.stopped_at = utcnow()
await self.session.flush()
class CommandRunRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_session(self, session_id: str) -> list[CommandRun]:
stmt = (
select(CommandRunRow)
.where(CommandRunRow.session_id == session_id)
.order_by(CommandRunRow.created_at)
)
return [
CommandRun(
id=r.id, session_id=r.session_id, command=r.command,
exit_code=r.exit_code, stdout_ref=r.stdout_ref, stderr_ref=r.stderr_ref,
duration_ms=r.duration_ms, truncated=r.truncated, created_at=r.created_at,
)
for r in (await self.session.execute(stmt)).scalars()
]
async def create(self, run: CommandRun) -> CommandRun:
row = CommandRunRow(
id=run.id, session_id=run.session_id, command=run.command,
exit_code=run.exit_code, stdout_ref=run.stdout_ref, stderr_ref=run.stderr_ref,
duration_ms=run.duration_ms, truncated=run.truncated, created_at=run.created_at,
)
self.session.add(row)
await self.session.flush()
return run
class SnapshotRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_workspace(self, workspace_id: str) -> list[Snapshot]:
stmt = (
select(SnapshotRow)
.where(SnapshotRow.workspace_id == workspace_id)
.order_by(SnapshotRow.created_at.desc())
)
return [
Snapshot(
id=r.id, workspace_id=r.workspace_id, name=r.name,
external_snapshot_id=r.external_snapshot_id, created_at=r.created_at,
)
for r in (await self.session.execute(stmt)).scalars()
]
async def create(self, snapshot: Snapshot) -> Snapshot:
row = SnapshotRow(
id=snapshot.id, workspace_id=snapshot.workspace_id, name=snapshot.name,
external_snapshot_id=snapshot.external_snapshot_id,
created_at=snapshot.created_at,
)
self.session.add(row)
await self.session.flush()
return snapshot
class PreviewUrlRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_workspace(self, workspace_id: str) -> list[PreviewUrl]:
stmt = select(PreviewUrlRow).where(PreviewUrlRow.workspace_id == workspace_id)
return [
PreviewUrl(
id=r.id, workspace_id=r.workspace_id, port=r.port, url=r.url,
created_at=r.created_at,
)
for r in (await self.session.execute(stmt)).scalars()
]
async def create(self, preview: PreviewUrl) -> PreviewUrl:
row = PreviewUrlRow(
id=preview.id, workspace_id=preview.workspace_id, port=preview.port,
url=preview.url, created_at=preview.created_at,
)
self.session.add(row)
await self.session.flush()
return preview
class ResourceLimitRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_scope(self, scope: str, scope_id: str | None) -> SandboxResourceLimit | None:
stmt = select(SandboxResourceLimitRow).where(
SandboxResourceLimitRow.scope == scope,
SandboxResourceLimitRow.scope_id == scope_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
return None
return SandboxResourceLimit(
id=row.id, scope=row.scope, scope_id=row.scope_id, cpu=row.cpu,
memory_mb=row.memory_mb, disk_mb=row.disk_mb,
timeout_seconds=row.timeout_seconds, max_output_bytes=row.max_output_bytes,
network_policy=NetworkPolicy(row.network_policy),
)
async def upsert(self, limit: SandboxResourceLimit) -> SandboxResourceLimit:
stmt = select(SandboxResourceLimitRow).where(
SandboxResourceLimitRow.scope == limit.scope,
SandboxResourceLimitRow.scope_id == limit.scope_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
row = SandboxResourceLimitRow(
id=limit.id, scope=limit.scope, scope_id=limit.scope_id, 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),
)
self.session.add(row)
else:
row.cpu = limit.cpu
row.memory_mb = limit.memory_mb
row.disk_mb = limit.disk_mb
row.timeout_seconds = limit.timeout_seconds
row.max_output_bytes = limit.max_output_bytes
row.network_policy = str(limit.network_policy)
await self.session.flush()
return limit
__all__ = [
"CommandRunRepository",
"PreviewUrlRepository",
"ResourceLimitRepository",
"SandboxProviderKind",
"SandboxSessionRepository",
"SandboxTemplateRepository",
"SnapshotRepository",
"WorkspaceRepository",
]
@@ -0,0 +1,684 @@
"""Repositories for the AI Workforce bounded context.
Covers agent templates, employees, prompt versions, tool permissions, and the
whole Manager-Chat chain: threads -> messages -> tasks -> runs -> steps ->
tool calls -> artifacts.
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.workforce.models import (
AgentTemplate,
Artifact,
Employee,
EmployeeStatus,
EmployeeToolPermission,
Message,
MessageRole,
PromptVersion,
RoleKey,
Run,
RunStatus,
RunStep,
Task,
TaskStatus,
Thread,
ToolCall,
ToolCallStatus,
)
from app.infrastructure.db.models import (
AgentTemplateRow,
ArtifactRow,
EmployeeRow,
EmployeeToolPermissionRow,
MessageRow,
PromptVersionRow,
RunRow,
RunStepRow,
TaskRow,
ThreadRow,
ToolCallRow,
)
# ---------------------------------------------------------------------------
# Mappers
# ---------------------------------------------------------------------------
def _template(row: AgentTemplateRow) -> AgentTemplate:
return AgentTemplate(
id=row.id, name=row.name, role_key=RoleKey(row.role_key),
description=row.description, default_prompt=row.default_prompt,
default_tools_json=list(row.default_tools_json or []),
default_routing_policy_id=row.default_routing_policy_id,
is_system=row.is_system, created_at=row.created_at,
)
def _employee(row: EmployeeRow) -> Employee:
return Employee(
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
template_id=row.template_id, name=row.name, role_key=RoleKey(row.role_key),
status=EmployeeStatus(row.status), routing_policy_id=row.routing_policy_id,
budget_limit_json=dict(row.budget_limit_json or {}), created_at=row.created_at,
)
def _thread(row: ThreadRow) -> Thread:
return Thread(
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
title=row.title, created_by_user_id=row.created_by_user_id,
created_at=row.created_at,
)
def _message(row: MessageRow) -> Message:
return Message(
id=row.id, thread_id=row.thread_id, role=MessageRole(row.role),
content=row.content, employee_id=row.employee_id, created_at=row.created_at,
)
def _task(row: TaskRow) -> Task:
return Task(
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
thread_id=row.thread_id, title=row.title, description=row.description,
status=TaskStatus(row.status),
assigned_employee_id=row.assigned_employee_id,
created_by_user_id=row.created_by_user_id,
created_at=row.created_at, updated_at=row.updated_at,
)
def _run(row: RunRow) -> Run:
return Run(
id=row.id, task_id=row.task_id, employee_id=row.employee_id,
status=RunStatus(row.status), error=row.error, started_at=row.started_at,
finished_at=row.finished_at, created_at=row.created_at,
)
def _run_step(row: RunStepRow) -> RunStep:
return RunStep(
id=row.id, run_id=row.run_id, index=row.index, kind=row.kind,
title=row.title, payload_json=dict(row.payload_json or {}),
created_at=row.created_at,
)
def _tool_call(row: ToolCallRow) -> ToolCall:
return ToolCall(
id=row.id, run_id=row.run_id, tool_key=row.tool_key,
arguments_json=dict(row.arguments_json or {}),
result_json=dict(row.result_json or {}),
status=ToolCallStatus(row.status), requires_approval=row.requires_approval,
approved_by_user_id=row.approved_by_user_id, created_at=row.created_at,
)
def _artifact(row: ArtifactRow) -> Artifact:
return Artifact(
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
run_id=row.run_id, name=row.name, kind=row.kind, content_ref=row.content_ref,
size_bytes=row.size_bytes, created_at=row.created_at,
)
# ---------------------------------------------------------------------------
# Templates & employees
# ---------------------------------------------------------------------------
class AgentTemplateRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list(self) -> list[AgentTemplate]:
stmt = select(AgentTemplateRow).order_by(AgentTemplateRow.role_key)
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
async def get(self, template_id: str) -> AgentTemplate | None:
row = await self.session.get(AgentTemplateRow, template_id)
return _template(row) if row else None
async def get_by_name(self, name: str) -> AgentTemplate | None:
stmt = select(AgentTemplateRow).where(AgentTemplateRow.name == name)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _template(row) if row else None
async def create(self, template: AgentTemplate) -> AgentTemplate:
row = AgentTemplateRow(
id=template.id, name=template.name, role_key=str(template.role_key),
description=template.description, default_prompt=template.default_prompt,
default_tools_json=list(template.default_tools_json),
default_routing_policy_id=template.default_routing_policy_id,
is_system=template.is_system, created_at=template.created_at,
)
self.session.add(row)
await self.session.flush()
return _template(row)
class EmployeeRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, employee_id: str) -> Employee | None:
row = await self.session.get(EmployeeRow, employee_id)
return _employee(row) if row else None
async def get_for_org(self, organization_id: str, employee_id: str) -> Employee | None:
"""Tenant-scoped lookup: an id from another org resolves to ``None``."""
stmt = select(EmployeeRow).where(
EmployeeRow.id == employee_id,
EmployeeRow.organization_id == organization_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _employee(row) if row else None
async def list_for_org(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Employee]:
stmt = (
select(EmployeeRow)
.where(EmployeeRow.organization_id == organization_id)
.order_by(EmployeeRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_for_project(self, project_id: str) -> list[Employee]:
stmt = (
select(EmployeeRow)
.where(EmployeeRow.project_id == project_id)
.order_by(EmployeeRow.created_at.desc())
)
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
async def count_for_org(self, organization_id: str) -> int:
stmt = select(func.count()).select_from(EmployeeRow).where(
EmployeeRow.organization_id == organization_id
)
return int((await self.session.execute(stmt)).scalar_one())
async def create(self, employee: Employee) -> Employee:
row = EmployeeRow(
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,
budget_limit_json=dict(employee.budget_limit_json),
created_at=employee.created_at,
)
self.session.add(row)
await self.session.flush()
return _employee(row)
async def update(self, employee: Employee) -> Employee:
row = await self.session.get(EmployeeRow, employee.id)
if row is None:
raise LookupError("employee not found")
row.name = employee.name
row.role_key = str(employee.role_key)
row.status = str(employee.status)
row.project_id = employee.project_id
row.routing_policy_id = employee.routing_policy_id
row.budget_limit_json = dict(employee.budget_limit_json)
await self.session.flush()
return _employee(row)
class PromptVersionRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_employee(self, employee_id: str) -> list[PromptVersion]:
stmt = (
select(PromptVersionRow)
.where(PromptVersionRow.employee_id == employee_id)
.order_by(PromptVersionRow.version.desc())
)
return [
PromptVersion(
id=r.id, employee_id=r.employee_id, version=r.version,
content=r.content, created_by_user_id=r.created_by_user_id,
created_at=r.created_at,
)
for r in (await self.session.execute(stmt)).scalars()
]
async def latest(self, employee_id: str) -> PromptVersion | None:
versions = await self.list_for_employee(employee_id)
return versions[0] if versions else None
async def next_version_number(self, employee_id: str) -> int:
stmt = select(func.max(PromptVersionRow.version)).where(
PromptVersionRow.employee_id == employee_id
)
current = (await self.session.execute(stmt)).scalar_one_or_none()
return int(current or 0) + 1
async def create(self, version: PromptVersion) -> PromptVersion:
row = PromptVersionRow(
id=version.id, employee_id=version.employee_id, version=version.version,
content=version.content, created_by_user_id=version.created_by_user_id,
created_at=version.created_at,
)
self.session.add(row)
await self.session.flush()
return PromptVersion(
id=row.id, employee_id=row.employee_id, version=row.version,
content=row.content, created_by_user_id=row.created_by_user_id,
created_at=row.created_at,
)
class EmployeeToolPermissionRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_employee(self, employee_id: str) -> list[EmployeeToolPermission]:
stmt = select(EmployeeToolPermissionRow).where(
EmployeeToolPermissionRow.employee_id == employee_id
)
return [
EmployeeToolPermission(
id=r.id, employee_id=r.employee_id, tool_key=r.tool_key,
allowed=r.allowed, policy_json=dict(r.policy_json or {}),
)
for r in (await self.session.execute(stmt)).scalars()
]
async def is_allowed(self, employee_id: str, tool_key: str) -> bool:
stmt = select(EmployeeToolPermissionRow.allowed).where(
EmployeeToolPermissionRow.employee_id == employee_id,
EmployeeToolPermissionRow.tool_key == tool_key,
)
value = (await self.session.execute(stmt)).scalar_one_or_none()
# Deny by default: an absent row means "not permitted".
return bool(value)
async def set_permission(
self, *, employee_id: str, tool_key: str, allowed: bool, policy: dict | None = None
) -> EmployeeToolPermission:
stmt = select(EmployeeToolPermissionRow).where(
EmployeeToolPermissionRow.employee_id == employee_id,
EmployeeToolPermissionRow.tool_key == tool_key,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
row = EmployeeToolPermissionRow(
employee_id=employee_id, tool_key=tool_key, allowed=allowed,
policy_json=dict(policy or {}),
)
self.session.add(row)
else:
row.allowed = allowed
row.policy_json = dict(policy or {})
await self.session.flush()
return EmployeeToolPermission(
id=row.id, employee_id=row.employee_id, tool_key=row.tool_key,
allowed=row.allowed, policy_json=dict(row.policy_json or {}),
)
# ---------------------------------------------------------------------------
# Chat: threads & messages
# ---------------------------------------------------------------------------
class ThreadRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, thread_id: str) -> Thread | None:
row = await self.session.get(ThreadRow, thread_id)
return _thread(row) if row else None
async def get_for_org(self, organization_id: str, thread_id: str) -> Thread | None:
stmt = select(ThreadRow).where(
ThreadRow.id == thread_id, ThreadRow.organization_id == organization_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _thread(row) if row else None
async def list_for_org(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Thread]:
stmt = (
select(ThreadRow)
.where(ThreadRow.organization_id == organization_id)
.order_by(ThreadRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_thread(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, thread: Thread) -> Thread:
row = ThreadRow(
id=thread.id, organization_id=thread.organization_id,
project_id=thread.project_id, title=thread.title,
created_by_user_id=thread.created_by_user_id, created_at=thread.created_at,
)
self.session.add(row)
await self.session.flush()
return _thread(row)
class MessageRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_thread(self, thread_id: str) -> list[Message]:
stmt = (
select(MessageRow)
.where(MessageRow.thread_id == thread_id)
.order_by(MessageRow.created_at)
)
return [_message(r) for r in (await self.session.execute(stmt)).scalars()]
async def count_for_thread(self, thread_id: str) -> int:
stmt = select(func.count()).select_from(MessageRow).where(
MessageRow.thread_id == thread_id
)
return int((await self.session.execute(stmt)).scalar_one())
async def create(self, message: Message) -> Message:
row = MessageRow(
id=message.id, thread_id=message.thread_id, role=str(message.role),
content=message.content, employee_id=message.employee_id,
created_at=message.created_at,
)
self.session.add(row)
await self.session.flush()
return _message(row)
# ---------------------------------------------------------------------------
# Work: tasks, runs, steps, tool calls, artifacts
# ---------------------------------------------------------------------------
class TaskRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, task_id: str) -> Task | None:
row = await self.session.get(TaskRow, task_id)
return _task(row) if row else None
async def get_for_org(self, organization_id: str, task_id: str) -> Task | None:
stmt = select(TaskRow).where(
TaskRow.id == task_id, TaskRow.organization_id == organization_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _task(row) if row else None
async def list_for_org(
self,
organization_id: str,
*,
status: TaskStatus | None = None,
limit: int = 100,
offset: int = 0,
) -> list[Task]:
stmt = select(TaskRow).where(TaskRow.organization_id == organization_id)
if status is not None:
stmt = stmt.where(TaskRow.status == str(status))
stmt = stmt.order_by(TaskRow.created_at.desc()).limit(min(limit, 500)).offset(offset)
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_for_employee(self, employee_id: str) -> list[Task]:
stmt = (
select(TaskRow)
.where(TaskRow.assigned_employee_id == employee_id)
.order_by(TaskRow.created_at.desc())
)
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_runnable(self, *, limit: int = 10) -> list[Task]:
"""Tasks the runtime worker should pick up, oldest first.
Cross-tenant by design: the worker is a platform component, not a
tenant. Only ``new``/``triaged`` qualify — anything else is either
already executing or closed, and re-running it would double-bill.
"""
stmt = (
select(TaskRow)
.where(TaskRow.status.in_([str(TaskStatus.NEW), str(TaskStatus.TRIAGED)]))
.order_by(TaskRow.created_at.asc())
.limit(min(limit, 100))
)
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
async def count_open_for_employee(self, employee_id: str) -> int:
stmt = (
select(func.count())
.select_from(TaskRow)
.where(
TaskRow.assigned_employee_id == employee_id,
TaskRow.status.notin_(
[str(TaskStatus.DONE), str(TaskStatus.FAILED), str(TaskStatus.CANCELLED)]
),
)
)
return int((await self.session.execute(stmt)).scalar_one())
async def create(self, task: Task) -> Task:
row = TaskRow(
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_by_user_id=task.created_by_user_id,
created_at=task.created_at, updated_at=task.updated_at,
)
self.session.add(row)
await self.session.flush()
return _task(row)
async def set_status(self, task_id: str, status: TaskStatus) -> Task | None:
row = await self.session.get(TaskRow, task_id)
if row is None:
return None
row.status = str(status)
await self.session.flush()
return _task(row)
class RunRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, run_id: str) -> Run | None:
row = await self.session.get(RunRow, run_id)
return _run(row) if row else None
async def list_for_task(self, task_id: str) -> list[Run]:
stmt = (
select(RunRow)
.where(RunRow.task_id == task_id)
.order_by(RunRow.created_at.desc())
)
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, run: Run) -> Run:
row = RunRow(
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,
)
self.session.add(row)
await self.session.flush()
return _run(row)
async def set_status(
self,
run_id: str,
status: RunStatus,
*,
error: str | None = None,
started_at=None,
finished_at=None,
) -> Run | None:
row = await self.session.get(RunRow, run_id)
if row is None:
return None
row.status = str(status)
if error is not None:
row.error = error
if started_at is not None:
row.started_at = started_at
if finished_at is not None:
row.finished_at = finished_at
await self.session.flush()
return _run(row)
class RunStepRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_run(self, run_id: str) -> list[RunStep]:
stmt = (
select(RunStepRow)
.where(RunStepRow.run_id == run_id)
.order_by(RunStepRow.index)
)
return [_run_step(r) for r in (await self.session.execute(stmt)).scalars()]
async def next_index(self, run_id: str) -> int:
stmt = select(func.max(RunStepRow.index)).where(RunStepRow.run_id == run_id)
current = (await self.session.execute(stmt)).scalar_one_or_none()
return int(current if current is not None else -1) + 1
async def append(
self, *, run_id: str, kind: str, title: str = "", payload: dict | None = None
) -> RunStep:
step = RunStep(
run_id=run_id, index=await self.next_index(run_id), kind=kind, title=title,
payload_json=dict(payload or {}),
)
row = RunStepRow(
id=step.id, run_id=step.run_id, index=step.index, kind=step.kind,
title=step.title, payload_json=step.payload_json, created_at=step.created_at,
)
self.session.add(row)
await self.session.flush()
return _run_step(row)
class ToolCallRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, call_id: str) -> ToolCall | None:
row = await self.session.get(ToolCallRow, call_id)
return _tool_call(row) if row else None
async def get_for_org(self, organization_id: str, call_id: str) -> ToolCall | None:
"""Tenant-scoped lookup, resolved through run -> task -> organization."""
stmt = (
select(ToolCallRow)
.join(RunRow, RunRow.id == ToolCallRow.run_id)
.join(TaskRow, TaskRow.id == RunRow.task_id)
.where(ToolCallRow.id == call_id, TaskRow.organization_id == organization_id)
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _tool_call(row) if row else None
async def list_for_run(self, run_id: str) -> list[ToolCall]:
stmt = (
select(ToolCallRow)
.where(ToolCallRow.run_id == run_id)
.order_by(ToolCallRow.created_at)
)
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_pending_approvals_for_org(
self, organization_id: str
) -> list[ToolCall]:
"""Tool calls awaiting approval, scoped through the owning run -> task -> org."""
stmt = (
select(ToolCallRow)
.join(RunRow, RunRow.id == ToolCallRow.run_id)
.join(TaskRow, TaskRow.id == RunRow.task_id)
.where(
TaskRow.organization_id == organization_id,
ToolCallRow.status == str(ToolCallStatus.AWAITING_APPROVAL),
)
.order_by(ToolCallRow.created_at)
)
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, call: ToolCall) -> ToolCall:
row = ToolCallRow(
id=call.id, run_id=call.run_id, tool_key=call.tool_key,
arguments_json=dict(call.arguments_json),
result_json=dict(call.result_json), status=str(call.status),
requires_approval=call.requires_approval,
approved_by_user_id=call.approved_by_user_id, created_at=call.created_at,
)
self.session.add(row)
await self.session.flush()
return _tool_call(row)
async def set_status(
self,
call_id: str,
status: ToolCallStatus,
*,
result: dict | None = None,
approved_by_user_id: str | None = None,
) -> ToolCall | None:
row = await self.session.get(ToolCallRow, call_id)
if row is None:
return None
row.status = str(status)
if result is not None:
row.result_json = dict(result)
if approved_by_user_id is not None:
row.approved_by_user_id = approved_by_user_id
await self.session.flush()
return _tool_call(row)
class ArtifactRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_for_org(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Artifact]:
stmt = (
select(ArtifactRow)
.where(ArtifactRow.organization_id == organization_id)
.order_by(ArtifactRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_for_run(self, run_id: str) -> list[Artifact]:
stmt = (
select(ArtifactRow)
.where(ArtifactRow.run_id == run_id)
.order_by(ArtifactRow.created_at)
)
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, artifact: Artifact) -> Artifact:
row = ArtifactRow(
id=artifact.id, organization_id=artifact.organization_id,
project_id=artifact.project_id, run_id=artifact.run_id,
name=artifact.name, kind=artifact.kind, content_ref=artifact.content_ref,
size_bytes=artifact.size_bytes, created_at=artifact.created_at,
)
self.session.add(row)
await self.session.flush()
return _artifact(row)