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

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ 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
+44
View File
@@ -0,0 +1,44 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
# The URL is read from the app settings in alembic/env.py, so nothing is
# duplicated here and no credential is committed.
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+66
View File
@@ -0,0 +1,66 @@
"""Alembic environment.
The database URL and the metadata come from the application itself, so a
migration can never drift from the models the app actually uses.
"""
from __future__ import annotations
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
# Importing the ORM module registers every table on Base.metadata.
import app.infrastructure.db.models # noqa: F401,E402
from alembic import context
from app.core.config import settings
from app.core.database import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _sync_url() -> str:
"""Alembic runs synchronously; strip the async driver from the URL."""
url = settings.database_url
return (
url.replace("+aiosqlite", "")
.replace("+asyncpg", "+psycopg")
.replace("+psycopg_async", "+psycopg")
)
def run_migrations_offline() -> None:
context.configure(
url=_sync_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
section = config.get_section(config.config_ini_section) or {}
section["sqlalchemy.url"] = _sync_url()
connectable = engine_from_config(
section, prefix="sqlalchemy.", poolclass=pool.NullPool
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,833 @@
"""initial schema
Revision ID: c7ecdf7882fe
Revises:
Create Date: 2026-10-12 21:29:22.888817
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = 'c7ecdf7882fe'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_templates',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('role_key', sa.String(length=32), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('default_prompt', sa.Text(), nullable=False),
sa.Column('default_tools_json', sa.JSON(), nullable=False),
sa.Column('default_routing_policy_id', sa.String(length=32), nullable=True),
sa.Column('is_system', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('ai_providers',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('type', sa.String(length=32), nullable=False),
sa.Column('base_url', sa.String(length=512), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('audit_events',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=True),
sa.Column('actor_user_id', sa.String(length=32), nullable=True),
sa.Column('action', sa.String(length=80), nullable=False),
sa.Column('target_type', sa.String(length=40), nullable=False),
sa.Column('target_id', sa.String(length=64), nullable=False),
sa.Column('metadata_json', sa.JSON(), nullable=False),
sa.Column('ip_address', sa.String(length=64), nullable=False),
sa.Column('user_agent', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_audit_action', 'audit_events', ['action'], unique=False)
op.create_index(op.f('ix_audit_events_actor_user_id'), 'audit_events', ['actor_user_id'], unique=False)
op.create_index(op.f('ix_audit_events_organization_id'), 'audit_events', ['organization_id'], unique=False)
op.create_index('ix_audit_org_created', 'audit_events', ['organization_id', 'created_at'], unique=False)
op.create_table('error_events',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=True),
sa.Column('run_id', sa.String(length=32), nullable=True),
sa.Column('kind', sa.String(length=80), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('stack', sa.Text(), nullable=False),
sa.Column('metadata_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_error_events_organization_id'), 'error_events', ['organization_id'], unique=False)
op.create_index(op.f('ix_error_events_run_id'), 'error_events', ['run_id'], unique=False)
op.create_table('plans',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('monthly_price', sa.Float(), nullable=False),
sa.Column('included_credit', sa.Float(), nullable=False),
sa.Column('features_json', sa.JSON(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('quality_metrics',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('value', sa.Float(), nullable=False),
sa.Column('dimensions_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_metric_org_name', 'quality_metrics', ['organization_id', 'name'], unique=False)
op.create_index(op.f('ix_quality_metrics_organization_id'), 'quality_metrics', ['organization_id'], unique=False)
op.create_table('rate_cards',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('unit', sa.String(length=40), nullable=False),
sa.Column('customer_price', sa.Float(), nullable=False),
sa.Column('internal_cost_estimate', sa.Float(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('unit')
)
op.create_table('routing_policies',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('mode', sa.String(length=16), nullable=False),
sa.Column('fallback_policy_json', sa.JSON(), nullable=False),
sa.Column('budget_limits_json', sa.JSON(), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('sandbox_resource_limits',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('scope', sa.String(length=32), nullable=False),
sa.Column('scope_id', sa.String(length=32), nullable=True),
sa.Column('cpu', sa.Float(), nullable=False),
sa.Column('memory_mb', sa.Integer(), nullable=False),
sa.Column('disk_mb', sa.Integer(), nullable=False),
sa.Column('timeout_seconds', sa.Integer(), nullable=False),
sa.Column('max_output_bytes', sa.Integer(), nullable=False),
sa.Column('network_policy', sa.String(length=16), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope', 'scope_id', name='uq_limit_scope')
)
op.create_table('sandbox_templates',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('image', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('default_cpu', sa.Float(), nullable=False),
sa.Column('default_memory_mb', sa.Integer(), nullable=False),
sa.Column('default_disk_mb', sa.Integer(), nullable=False),
sa.Column('is_system', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('traces',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('run_id', sa.String(length=32), nullable=True),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_traces_organization_id'), 'traces', ['organization_id'], unique=False)
op.create_index(op.f('ix_traces_run_id'), 'traces', ['run_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('avatar_url', sa.String(length=512), nullable=True),
sa.Column('is_platform_owner', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('ai_model_credentials',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('provider_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('encrypted_api_key', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['provider_id'], ['ai_providers.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_ai_model_credentials_provider_id'), 'ai_model_credentials', ['provider_id'], unique=False)
op.create_table('ai_models',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('provider_id', sa.String(length=32), nullable=False),
sa.Column('internal_name', sa.String(length=160), nullable=False),
sa.Column('display_name_for_admin', sa.String(length=160), nullable=False),
sa.Column('capabilities', sa.JSON(), nullable=False),
sa.Column('input_price_per_1m', sa.Float(), nullable=False),
sa.Column('output_price_per_1m', sa.Float(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['provider_id'], ['ai_providers.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider_id', 'internal_name', name='uq_model_provider_name')
)
op.create_index(op.f('ix_ai_models_provider_id'), 'ai_models', ['provider_id'], unique=False)
op.create_table('organizations',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('slug', sa.String(length=64), nullable=False),
sa.Column('owner_user_id', sa.String(length=32), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_organizations_owner_user_id'), 'organizations', ['owner_user_id'], unique=False)
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True)
op.create_table('spans',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('trace_id', sa.String(length=32), nullable=False),
sa.Column('parent_span_id', sa.String(length=32), nullable=True),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('kind', sa.String(length=32), nullable=False),
sa.Column('attributes_json', sa.JSON(), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['trace_id'], ['traces.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_spans_trace_id'), 'spans', ['trace_id'], unique=False)
op.create_table('credit_wallets',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('balance', sa.Float(), nullable=False),
sa.Column('currency', sa.String(length=8), nullable=False),
sa.Column('low_balance_threshold', sa.Float(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_credit_wallets_organization_id'), 'credit_wallets', ['organization_id'], unique=True)
op.create_table('invites',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('token_hash', sa.String(length=128), nullable=False),
sa.Column('invited_by_user_id', sa.String(length=32), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('accepted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['invited_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
op.create_index('ix_invite_org_email', 'invites', ['organization_id', 'email'], unique=False)
op.create_index(op.f('ix_invites_invited_by_user_id'), 'invites', ['invited_by_user_id'], unique=False)
op.create_index(op.f('ix_invites_organization_id'), 'invites', ['organization_id'], unique=False)
op.create_table('invoices',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('currency', sa.String(length=8), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('external_invoice_id', sa.String(length=160), nullable=True),
sa.Column('period_start', sa.DateTime(timezone=True), nullable=True),
sa.Column('period_end', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoices_organization_id'), 'invoices', ['organization_id'], unique=False)
op.create_table('knowledge_sources',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('kind', sa.String(length=16), nullable=False),
sa.Column('uri', sa.String(length=512), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_knowledge_sources_organization_id'), 'knowledge_sources', ['organization_id'], unique=False)
op.create_index(op.f('ix_knowledge_sources_project_id'), 'knowledge_sources', ['project_id'], unique=False)
op.create_table('memberships',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organization_id', 'user_id', name='uq_membership_org_user')
)
op.create_index('ix_membership_org_role', 'memberships', ['organization_id', 'role'], unique=False)
op.create_index(op.f('ix_memberships_organization_id'), 'memberships', ['organization_id'], unique=False)
op.create_index(op.f('ix_memberships_user_id'), 'memberships', ['user_id'], unique=False)
op.create_table('payments',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('external_payment_id', sa.String(length=160), nullable=True),
sa.Column('invoice_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_payments_organization_id'), 'payments', ['organization_id'], unique=False)
op.create_table('projects',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('slug', sa.String(length=64), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
sa.Column('default_branch', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organization_id', 'slug', name='uq_project_org_slug')
)
op.create_index('ix_project_org_created', 'projects', ['organization_id', 'created_at'], unique=False)
op.create_index(op.f('ix_projects_created_by_user_id'), 'projects', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_projects_organization_id'), 'projects', ['organization_id'], unique=False)
op.create_table('retrieval_policies',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('scope_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_retrieval_policies_organization_id'), 'retrieval_policies', ['organization_id'], unique=False)
op.create_table('subscriptions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('plan_id', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('external_subscription_id', sa.String(length=160), nullable=True),
sa.Column('current_period_end', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_subscriptions_organization_id'), 'subscriptions', ['organization_id'], unique=False)
op.create_index(op.f('ix_subscriptions_plan_id'), 'subscriptions', ['plan_id'], unique=False)
op.create_table('usage_events',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=True),
sa.Column('user_id', sa.String(length=32), nullable=True),
sa.Column('employee_id', sa.String(length=32), nullable=True),
sa.Column('run_id', sa.String(length=32), nullable=True),
sa.Column('unit', sa.String(length=40), nullable=False),
sa.Column('quantity', sa.Float(), nullable=False),
sa.Column('unit_price', sa.Float(), nullable=False),
sa.Column('internal_cost_estimate', sa.Float(), nullable=False),
sa.Column('total_price', sa.Float(), nullable=False),
sa.Column('metadata_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_usage_events_employee_id'), 'usage_events', ['employee_id'], unique=False)
op.create_index(op.f('ix_usage_events_organization_id'), 'usage_events', ['organization_id'], unique=False)
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
op.create_index(op.f('ix_usage_events_run_id'), 'usage_events', ['run_id'], unique=False)
op.create_index('ix_usage_org_created', 'usage_events', ['organization_id', 'created_at'], unique=False)
op.create_index('ix_usage_org_unit', 'usage_events', ['organization_id', 'unit'], unique=False)
op.create_table('documents',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('source_id', sa.String(length=32), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['source_id'], ['knowledge_sources.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_documents_source_id'), 'documents', ['source_id'], unique=False)
op.create_table('embedding_jobs',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('source_id', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['source_id'], ['knowledge_sources.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_embedding_jobs_source_id'), 'embedding_jobs', ['source_id'], unique=False)
op.create_table('employees',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=True),
sa.Column('template_id', sa.String(length=32), nullable=True),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('role_key', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('routing_policy_id', sa.String(length=32), nullable=True),
sa.Column('budget_limit_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_employee_org_created', 'employees', ['organization_id', 'created_at'], unique=False)
op.create_index(op.f('ix_employees_organization_id'), 'employees', ['organization_id'], unique=False)
op.create_index(op.f('ix_employees_project_id'), 'employees', ['project_id'], unique=False)
op.create_table('preview_domains',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('host', sa.String(length=255), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('protected', sa.Boolean(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('host', name='uq_preview_domain_host')
)
op.create_index('ix_preview_domain_org_created', 'preview_domains', ['organization_id', 'created_at'], unique=False)
op.create_index(op.f('ix_preview_domains_created_by_user_id'), 'preview_domains', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_preview_domains_organization_id'), 'preview_domains', ['organization_id'], unique=False)
op.create_index(op.f('ix_preview_domains_project_id'), 'preview_domains', ['project_id'], unique=False)
op.create_table('preview_ports',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('service_name', sa.String(length=80), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('port', name='uq_preview_port_port'),
sa.UniqueConstraint('project_id', 'service_name', name='uq_preview_port_project_service')
)
op.create_index(op.f('ix_preview_ports_organization_id'), 'preview_ports', ['organization_id'], unique=False)
op.create_index(op.f('ix_preview_ports_project_id'), 'preview_ports', ['project_id'], unique=False)
op.create_table('project_git_credentials',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('username', sa.String(length=160), nullable=False),
sa.Column('encrypted_password', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_project_git_credentials_project_id'), 'project_git_credentials', ['project_id'], unique=True)
op.create_table('project_repositories',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('owner', sa.String(length=120), nullable=False),
sa.Column('repo_name', sa.String(length=160), nullable=False),
sa.Column('clone_url', sa.String(length=512), nullable=False),
sa.Column('web_url', sa.String(length=512), nullable=False),
sa.Column('default_branch', sa.String(length=80), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_project_repositories_project_id'), 'project_repositories', ['project_id'], unique=True)
op.create_table('threads',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_thread_org_created', 'threads', ['organization_id', 'created_at'], unique=False)
op.create_index(op.f('ix_threads_created_by_user_id'), 'threads', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_threads_organization_id'), 'threads', ['organization_id'], unique=False)
op.create_index(op.f('ix_threads_project_id'), 'threads', ['project_id'], unique=False)
op.create_table('workspaces',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('template_id', sa.String(length=32), nullable=False),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('external_workspace_id', sa.String(length=160), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['template_id'], ['sandbox_templates.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_workspace_project_created', 'workspaces', ['project_id', 'created_at'], unique=False)
op.create_index(op.f('ix_workspaces_organization_id'), 'workspaces', ['organization_id'], unique=False)
op.create_index(op.f('ix_workspaces_project_id'), 'workspaces', ['project_id'], unique=False)
op.create_index(op.f('ix_workspaces_template_id'), 'workspaces', ['template_id'], unique=False)
op.create_table('document_chunks',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('document_id', sa.String(length=32), nullable=False),
sa.Column('index', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('embedding_json', sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_chunk_document_index', 'document_chunks', ['document_id', 'index'], unique=False)
op.create_index(op.f('ix_document_chunks_document_id'), 'document_chunks', ['document_id'], unique=False)
op.create_table('employee_tool_permissions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('employee_id', sa.String(length=32), nullable=False),
sa.Column('tool_key', sa.String(length=80), nullable=False),
sa.Column('allowed', sa.Boolean(), nullable=False),
sa.Column('policy_json', sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('employee_id', 'tool_key', name='uq_employee_tool')
)
op.create_index(op.f('ix_employee_tool_permissions_employee_id'), 'employee_tool_permissions', ['employee_id'], unique=False)
op.create_table('messages',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('thread_id', sa.String(length=32), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('employee_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['thread_id'], ['threads.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_message_thread_created', 'messages', ['thread_id', 'created_at'], unique=False)
op.create_index(op.f('ix_messages_employee_id'), 'messages', ['employee_id'], unique=False)
op.create_index(op.f('ix_messages_thread_id'), 'messages', ['thread_id'], unique=False)
op.create_table('preview_urls',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('workspace_id', sa.String(length=32), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('url', sa.String(length=512), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_preview_urls_workspace_id'), 'preview_urls', ['workspace_id'], unique=False)
op.create_table('prompt_versions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('employee_id', sa.String(length=32), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('employee_id', 'version', name='uq_prompt_employee_version')
)
op.create_index(op.f('ix_prompt_versions_created_by_user_id'), 'prompt_versions', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_prompt_versions_employee_id'), 'prompt_versions', ['employee_id'], unique=False)
op.create_table('sandbox_sessions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('workspace_id', sa.String(length=32), nullable=False),
sa.Column('external_session_id', sa.String(length=160), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('stopped_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sandbox_sessions_workspace_id'), 'sandbox_sessions', ['workspace_id'], unique=False)
op.create_table('snapshots',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('workspace_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('external_snapshot_id', sa.String(length=160), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_snapshots_workspace_id'), 'snapshots', ['workspace_id'], unique=False)
op.create_table('tasks',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=True),
sa.Column('thread_id', sa.String(length=32), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=24), nullable=False),
sa.Column('assigned_employee_id', sa.String(length=32), nullable=True),
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['thread_id'], ['threads.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_task_org_created', 'tasks', ['organization_id', 'created_at'], unique=False)
op.create_index('ix_task_org_status', 'tasks', ['organization_id', 'status'], unique=False)
op.create_index(op.f('ix_tasks_assigned_employee_id'), 'tasks', ['assigned_employee_id'], unique=False)
op.create_index(op.f('ix_tasks_created_by_user_id'), 'tasks', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_tasks_organization_id'), 'tasks', ['organization_id'], unique=False)
op.create_index(op.f('ix_tasks_project_id'), 'tasks', ['project_id'], unique=False)
op.create_index(op.f('ix_tasks_thread_id'), 'tasks', ['thread_id'], unique=False)
op.create_table('command_runs',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('session_id', sa.String(length=32), nullable=False),
sa.Column('command', sa.Text(), nullable=False),
sa.Column('exit_code', sa.Integer(), nullable=True),
sa.Column('stdout_ref', sa.Text(), nullable=False),
sa.Column('stderr_ref', sa.Text(), nullable=False),
sa.Column('duration_ms', sa.Integer(), nullable=False),
sa.Column('truncated', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['sandbox_sessions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_command_runs_session_id'), 'command_runs', ['session_id'], unique=False)
op.create_table('runs',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('task_id', sa.String(length=32), nullable=False),
sa.Column('employee_id', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=24), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_run_task_created', 'runs', ['task_id', 'created_at'], unique=False)
op.create_index(op.f('ix_runs_employee_id'), 'runs', ['employee_id'], unique=False)
op.create_index(op.f('ix_runs_task_id'), 'runs', ['task_id'], unique=False)
op.create_table('artifacts',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('organization_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=True),
sa.Column('run_id', sa.String(length=32), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('kind', sa.String(length=32), nullable=False),
sa.Column('content_ref', sa.Text(), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_artifact_org_created', 'artifacts', ['organization_id', 'created_at'], unique=False)
op.create_index(op.f('ix_artifacts_organization_id'), 'artifacts', ['organization_id'], unique=False)
op.create_index(op.f('ix_artifacts_project_id'), 'artifacts', ['project_id'], unique=False)
op.create_index(op.f('ix_artifacts_run_id'), 'artifacts', ['run_id'], unique=False)
op.create_table('run_steps',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('run_id', sa.String(length=32), nullable=False),
sa.Column('index', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=40), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('payload_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_run_step_run_index', 'run_steps', ['run_id', 'index'], unique=False)
op.create_index(op.f('ix_run_steps_run_id'), 'run_steps', ['run_id'], unique=False)
op.create_table('tool_calls',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('run_id', sa.String(length=32), nullable=False),
sa.Column('tool_key', sa.String(length=80), nullable=False),
sa.Column('arguments_json', sa.JSON(), nullable=False),
sa.Column('result_json', sa.JSON(), nullable=False),
sa.Column('status', sa.String(length=24), nullable=False),
sa.Column('requires_approval', sa.Boolean(), nullable=False),
sa.Column('approved_by_user_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['approved_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_tool_call_run_created', 'tool_calls', ['run_id', 'created_at'], unique=False)
op.create_index('ix_tool_call_status', 'tool_calls', ['status'], unique=False)
op.create_index(op.f('ix_tool_calls_approved_by_user_id'), 'tool_calls', ['approved_by_user_id'], unique=False)
op.create_index(op.f('ix_tool_calls_run_id'), 'tool_calls', ['run_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_tool_calls_run_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_approved_by_user_id'), table_name='tool_calls')
op.drop_index('ix_tool_call_status', table_name='tool_calls')
op.drop_index('ix_tool_call_run_created', table_name='tool_calls')
op.drop_table('tool_calls')
op.drop_index(op.f('ix_run_steps_run_id'), table_name='run_steps')
op.drop_index('ix_run_step_run_index', table_name='run_steps')
op.drop_table('run_steps')
op.drop_index(op.f('ix_artifacts_run_id'), table_name='artifacts')
op.drop_index(op.f('ix_artifacts_project_id'), table_name='artifacts')
op.drop_index(op.f('ix_artifacts_organization_id'), table_name='artifacts')
op.drop_index('ix_artifact_org_created', table_name='artifacts')
op.drop_table('artifacts')
op.drop_index(op.f('ix_runs_task_id'), table_name='runs')
op.drop_index(op.f('ix_runs_employee_id'), table_name='runs')
op.drop_index('ix_run_task_created', table_name='runs')
op.drop_table('runs')
op.drop_index(op.f('ix_command_runs_session_id'), table_name='command_runs')
op.drop_table('command_runs')
op.drop_index(op.f('ix_tasks_thread_id'), table_name='tasks')
op.drop_index(op.f('ix_tasks_project_id'), table_name='tasks')
op.drop_index(op.f('ix_tasks_organization_id'), table_name='tasks')
op.drop_index(op.f('ix_tasks_created_by_user_id'), table_name='tasks')
op.drop_index(op.f('ix_tasks_assigned_employee_id'), table_name='tasks')
op.drop_index('ix_task_org_status', table_name='tasks')
op.drop_index('ix_task_org_created', table_name='tasks')
op.drop_table('tasks')
op.drop_index(op.f('ix_snapshots_workspace_id'), table_name='snapshots')
op.drop_table('snapshots')
op.drop_index(op.f('ix_sandbox_sessions_workspace_id'), table_name='sandbox_sessions')
op.drop_table('sandbox_sessions')
op.drop_index(op.f('ix_prompt_versions_employee_id'), table_name='prompt_versions')
op.drop_index(op.f('ix_prompt_versions_created_by_user_id'), table_name='prompt_versions')
op.drop_table('prompt_versions')
op.drop_index(op.f('ix_preview_urls_workspace_id'), table_name='preview_urls')
op.drop_table('preview_urls')
op.drop_index(op.f('ix_messages_thread_id'), table_name='messages')
op.drop_index(op.f('ix_messages_employee_id'), table_name='messages')
op.drop_index('ix_message_thread_created', table_name='messages')
op.drop_table('messages')
op.drop_index(op.f('ix_employee_tool_permissions_employee_id'), table_name='employee_tool_permissions')
op.drop_table('employee_tool_permissions')
op.drop_index(op.f('ix_document_chunks_document_id'), table_name='document_chunks')
op.drop_index('ix_chunk_document_index', table_name='document_chunks')
op.drop_table('document_chunks')
op.drop_index(op.f('ix_workspaces_template_id'), table_name='workspaces')
op.drop_index(op.f('ix_workspaces_project_id'), table_name='workspaces')
op.drop_index(op.f('ix_workspaces_organization_id'), table_name='workspaces')
op.drop_index('ix_workspace_project_created', table_name='workspaces')
op.drop_table('workspaces')
op.drop_index(op.f('ix_threads_project_id'), table_name='threads')
op.drop_index(op.f('ix_threads_organization_id'), table_name='threads')
op.drop_index(op.f('ix_threads_created_by_user_id'), table_name='threads')
op.drop_index('ix_thread_org_created', table_name='threads')
op.drop_table('threads')
op.drop_index(op.f('ix_project_repositories_project_id'), table_name='project_repositories')
op.drop_table('project_repositories')
op.drop_index(op.f('ix_project_git_credentials_project_id'), table_name='project_git_credentials')
op.drop_table('project_git_credentials')
op.drop_index(op.f('ix_preview_ports_project_id'), table_name='preview_ports')
op.drop_index(op.f('ix_preview_ports_organization_id'), table_name='preview_ports')
op.drop_table('preview_ports')
op.drop_index(op.f('ix_preview_domains_project_id'), table_name='preview_domains')
op.drop_index(op.f('ix_preview_domains_organization_id'), table_name='preview_domains')
op.drop_index(op.f('ix_preview_domains_created_by_user_id'), table_name='preview_domains')
op.drop_index('ix_preview_domain_org_created', table_name='preview_domains')
op.drop_table('preview_domains')
op.drop_index(op.f('ix_employees_project_id'), table_name='employees')
op.drop_index(op.f('ix_employees_organization_id'), table_name='employees')
op.drop_index('ix_employee_org_created', table_name='employees')
op.drop_table('employees')
op.drop_index(op.f('ix_embedding_jobs_source_id'), table_name='embedding_jobs')
op.drop_table('embedding_jobs')
op.drop_index(op.f('ix_documents_source_id'), table_name='documents')
op.drop_table('documents')
op.drop_index('ix_usage_org_unit', table_name='usage_events')
op.drop_index('ix_usage_org_created', table_name='usage_events')
op.drop_index(op.f('ix_usage_events_run_id'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_organization_id'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_employee_id'), table_name='usage_events')
op.drop_table('usage_events')
op.drop_index(op.f('ix_subscriptions_plan_id'), table_name='subscriptions')
op.drop_index(op.f('ix_subscriptions_organization_id'), table_name='subscriptions')
op.drop_table('subscriptions')
op.drop_index(op.f('ix_retrieval_policies_organization_id'), table_name='retrieval_policies')
op.drop_table('retrieval_policies')
op.drop_index(op.f('ix_projects_organization_id'), table_name='projects')
op.drop_index(op.f('ix_projects_created_by_user_id'), table_name='projects')
op.drop_index('ix_project_org_created', table_name='projects')
op.drop_table('projects')
op.drop_index(op.f('ix_payments_organization_id'), table_name='payments')
op.drop_table('payments')
op.drop_index(op.f('ix_memberships_user_id'), table_name='memberships')
op.drop_index(op.f('ix_memberships_organization_id'), table_name='memberships')
op.drop_index('ix_membership_org_role', table_name='memberships')
op.drop_table('memberships')
op.drop_index(op.f('ix_knowledge_sources_project_id'), table_name='knowledge_sources')
op.drop_index(op.f('ix_knowledge_sources_organization_id'), table_name='knowledge_sources')
op.drop_table('knowledge_sources')
op.drop_index(op.f('ix_invoices_organization_id'), table_name='invoices')
op.drop_table('invoices')
op.drop_index(op.f('ix_invites_organization_id'), table_name='invites')
op.drop_index(op.f('ix_invites_invited_by_user_id'), table_name='invites')
op.drop_index('ix_invite_org_email', table_name='invites')
op.drop_table('invites')
op.drop_index(op.f('ix_credit_wallets_organization_id'), table_name='credit_wallets')
op.drop_table('credit_wallets')
op.drop_index(op.f('ix_spans_trace_id'), table_name='spans')
op.drop_table('spans')
op.drop_index(op.f('ix_organizations_slug'), table_name='organizations')
op.drop_index(op.f('ix_organizations_owner_user_id'), table_name='organizations')
op.drop_table('organizations')
op.drop_index(op.f('ix_ai_models_provider_id'), table_name='ai_models')
op.drop_table('ai_models')
op.drop_index(op.f('ix_ai_model_credentials_provider_id'), table_name='ai_model_credentials')
op.drop_table('ai_model_credentials')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_traces_run_id'), table_name='traces')
op.drop_index(op.f('ix_traces_organization_id'), table_name='traces')
op.drop_table('traces')
op.drop_table('sandbox_templates')
op.drop_table('sandbox_resource_limits')
op.drop_table('routing_policies')
op.drop_table('rate_cards')
op.drop_index(op.f('ix_quality_metrics_organization_id'), table_name='quality_metrics')
op.drop_index('ix_metric_org_name', table_name='quality_metrics')
op.drop_table('quality_metrics')
op.drop_table('plans')
op.drop_index(op.f('ix_error_events_run_id'), table_name='error_events')
op.drop_index(op.f('ix_error_events_organization_id'), table_name='error_events')
op.drop_table('error_events')
op.drop_index('ix_audit_org_created', table_name='audit_events')
op.drop_index(op.f('ix_audit_events_organization_id'), table_name='audit_events')
op.drop_index(op.f('ix_audit_events_actor_user_id'), table_name='audit_events')
op.drop_index('ix_audit_action', table_name='audit_events')
op.drop_table('audit_events')
op.drop_table('ai_providers')
op.drop_table('agent_templates')
# ### end Alembic commands ###
@@ -0,0 +1,87 @@
"""extend error events for the error console
Turns ``error_events`` from a stub (``kind``/``message``/``stack``) into a record
detailed enough to diagnose a failure without reproducing it: where it happened
(``source``), what threw (``error_type``), who hit it (``user_id``, ``ip``), on
which request (``method``, ``path``, ``request_id``), and whether anyone has
dealt with it (``resolved_at``).
Every new NOT NULL column carries a ``server_default`` so existing rows backfill
without a table rewrite, and so the row stays insertable from plain SQL.
Revision ID: f5ba57af2906
Revises: c7ecdf7882fe
Create Date: 2026-10-12 23:17:57.352385
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = 'f5ba57af2906'
down_revision: str | None = 'c7ecdf7882fe'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
#: ``(name, type, server_default)``. ``None`` default means nullable, no default.
_NEW_COLUMNS: tuple[tuple[str, sa.types.TypeEngine, str | None], ...] = (
("source", sa.String(length=16), "'http'"),
("severity", sa.String(length=16), "'error'"),
("error_type", sa.String(length=200), "''"),
("fingerprint", sa.String(length=64), "''"),
("status_code", sa.Integer(), None),
("method", sa.String(length=10), "''"),
("path", sa.String(length=512), "''"),
("user_id", sa.String(length=32), None),
("request_id", sa.String(length=64), "''"),
("ip", sa.String(length=64), "''"),
("user_agent", sa.String(length=255), "''"),
("resolved_at", sa.DateTime(timezone=True), None),
("resolved_note", sa.Text(), "''"),
)
def upgrade() -> None:
for name, column_type, default in _NEW_COLUMNS:
op.add_column(
"error_events",
sa.Column(
name,
column_type,
nullable=default is None,
server_default=None if default is None else sa.text(default),
),
)
op.create_index(
op.f("ix_error_events_fingerprint"), "error_events", ["fingerprint"], unique=False
)
op.create_index(
op.f("ix_error_events_resolved_at"), "error_events", ["resolved_at"], unique=False
)
op.create_index(
"ix_error_events_fingerprint_created",
"error_events",
["fingerprint", "created_at"],
unique=False,
)
op.create_index(
"ix_error_events_org_created",
"error_events",
["organization_id", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_error_events_org_created", table_name="error_events")
op.drop_index("ix_error_events_fingerprint_created", table_name="error_events")
op.drop_index(op.f("ix_error_events_resolved_at"), table_name="error_events")
op.drop_index(op.f("ix_error_events_fingerprint"), table_name="error_events")
for name, _column_type, _default in reversed(_NEW_COLUMNS):
op.drop_column("error_events", name)
+6
View File
@@ -0,0 +1,6 @@
"""AGY Web backend — standalone multi-tenant SaaS.
Independent of the Telegram bot runtime: nothing outside this package is imported.
"""
__version__ = "0.1.0"
+165
View File
@@ -0,0 +1,165 @@
"""Composition root for the application services.
This module builds every application service from a single ``AsyncSession``.
It exists so that the HTTP layer and the standalone workers share **one**
wiring: if the API and a worker ever disagreed about which adapter backs a
port, that would be a bug, and having two copies of the wiring is exactly how
such a bug appears.
Deliberately free of FastAPI: the HTTP dependency functions in
``app.interfaces.http.deps`` delegate here, and so do the workers in
``app.workers``. Nothing in the inner layers imports this module.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService
from app.application.services.sandbox_service import SandboxService
from app.application.services.workforce_service import WorkforceService
from app.infrastructure.adapters.registry import (
get_domain_router,
get_git_provider,
get_model_provider,
get_payment_provider,
get_sandbox_provider,
get_vector_store,
)
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import (
CreditWalletRepository,
InvoiceRepository,
PaymentRepository,
PlanRepository,
SubscriptionRepository,
)
from app.infrastructure.repositories.knowledge import (
DocumentChunkRepository,
DocumentRepository,
EmbeddingJobRepository,
KnowledgeSourceRepository,
RetrievalPolicyRepository,
)
from app.infrastructure.repositories.projects import (
PreviewDomainRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
from app.infrastructure.repositories.sandbox import (
CommandRunRepository,
PreviewUrlRepository,
ResourceLimitRepository,
SandboxSessionRepository,
SandboxTemplateRepository,
SnapshotRepository,
WorkspaceRepository,
)
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
ArtifactRepository,
EmployeeRepository,
EmployeeToolPermissionRepository,
MessageRepository,
PromptVersionRepository,
RunRepository,
RunStepRepository,
TaskRepository,
ThreadRepository,
ToolCallRepository,
)
def build_project_service(session: AsyncSession) -> ProjectService:
"""Project + Git repo + preview domain, with the configured adapters."""
return ProjectService(
projects=ProjectRepository(session),
repos=ProjectGitRepoRepository(session),
credentials=ProjectGitCredentialRepository(session),
domains=PreviewDomainRepository(session),
git=get_git_provider(),
router=get_domain_router(),
)
def build_workforce_service(session: AsyncSession) -> WorkforceService:
"""Employees, threads, tasks and the run engine."""
return WorkforceService(
employees=EmployeeRepository(session),
templates=AgentTemplateRepository(session),
prompts=PromptVersionRepository(session),
permissions=EmployeeToolPermissionRepository(session),
threads=ThreadRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
runs=RunRepository(session),
run_steps=RunStepRepository(session),
tool_calls=ToolCallRepository(session),
artifacts=ArtifactRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
routing_policies=RoutingPolicyRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
provider=get_model_provider(),
)
def build_billing_service(session: AsyncSession) -> BillingService:
"""Wallets, invoices, payments and the usage ledger."""
return BillingService(
wallets=CreditWalletRepository(session),
invoices=InvoiceRepository(session),
payments=PaymentRepository(session),
plans=PlanRepository(session),
subscriptions=SubscriptionRepository(session),
ledger=UsageLedgerRepository(session),
payment_provider=get_payment_provider(),
)
def build_knowledge_service(session: AsyncSession) -> KnowledgeService:
"""Knowledge sources, documents, chunks and the vector store."""
return KnowledgeService(
sources=KnowledgeSourceRepository(session),
documents=DocumentRepository(session),
chunks=DocumentChunkRepository(session),
jobs=EmbeddingJobRepository(session),
policies=RetrievalPolicyRepository(session),
vector_store=get_vector_store(),
)
def build_sandbox_service(session: AsyncSession) -> SandboxService:
"""The sandbox gateway: the only sanctioned path for running code."""
return SandboxService(
templates=SandboxTemplateRepository(session),
workspaces=WorkspaceRepository(session),
sessions=SandboxSessionRepository(session),
commands=CommandRunRepository(session),
snapshots=SnapshotRepository(session),
previews=PreviewUrlRepository(session),
limits=ResourceLimitRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
projects=ProjectRepository(session),
provider=get_sandbox_provider(),
)
__all__ = [
"build_billing_service",
"build_knowledge_service",
"build_project_service",
"build_sandbox_service",
"build_workforce_service",
]
@@ -0,0 +1,92 @@
"""Application-layer ports (driven interfaces).
Every port has at least one *mock* adapter so the whole stack runs with zero
external credentials. Concrete adapters live in ``app.infrastructure.adapters``
and are selected by the ``*_PROVIDER`` environment switches.
"""
from app.application.ports.audit import (
AuditPort,
UsageAggregate,
UsageBreakdown,
UsageLedgerPort,
UsageRecord,
)
from app.application.ports.domain_router import (
DomainRouterPort,
PreviewDomainInfo,
ReverseProxyResult,
)
from app.application.ports.git_provider import (
BranchInfo,
CommitInfo,
GitCredentials,
GitProviderPort,
MergeResult,
PullRequestInfo,
RepoInfo,
RepoStatus,
)
from app.application.ports.model_provider import (
ChatMessage,
ModelHealth,
ModelProviderPort,
ModelResponse,
)
from app.application.ports.payment_provider import (
CheckoutSession,
PaymentProviderPort,
PaymentResult,
)
from app.application.ports.sandbox_provider import (
CommandResult,
SandboxProviderPort,
SessionInfo,
SnapshotInfo,
WorkspaceInfo,
)
from app.application.ports.storage import (
Notification,
NotificationPort,
StoragePort,
StoredObject,
VectorHit,
VectorStorePort,
)
__all__ = [
"AuditPort",
"BranchInfo",
"ChatMessage",
"CheckoutSession",
"CommandResult",
"CommitInfo",
"DomainRouterPort",
"GitCredentials",
"GitProviderPort",
"MergeResult",
"ModelHealth",
"ModelProviderPort",
"ModelResponse",
"Notification",
"NotificationPort",
"PaymentProviderPort",
"PaymentResult",
"PreviewDomainInfo",
"PullRequestInfo",
"RepoInfo",
"RepoStatus",
"ReverseProxyResult",
"SandboxProviderPort",
"SessionInfo",
"SnapshotInfo",
"StoragePort",
"StoredObject",
"UsageAggregate",
"UsageBreakdown",
"UsageLedgerPort",
"UsageRecord",
"VectorHit",
"VectorStorePort",
"WorkspaceInfo",
]
+73
View File
@@ -0,0 +1,73 @@
"""Audit and usage-ledger ports.
``AuditPort`` writes the immutable trail; ``UsageLedgerPort`` is the billing
source of truth. Both are append-only by contract.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from app.domain.ai_control.models import RateUnit
from app.domain.audit.models import AuditEvent
@dataclass(slots=True, kw_only=True)
class UsageRecord:
organization_id: str
unit: RateUnit
quantity: float
unit_price: float = 0.0
internal_cost_estimate: float = 0.0
total_price: float = 0.0
project_id: str | None = None
user_id: str | None = None
employee_id: str | None = None
run_id: str | None = None
metadata_json: dict = field(default_factory=dict)
@dataclass(slots=True, kw_only=True)
class UsageAggregate:
unit: RateUnit
quantity: float = 0.0
total_price: float = 0.0
internal_cost_estimate: float = 0.0
@dataclass(slots=True, kw_only=True)
class UsageBreakdown:
"""Grouped totals for the billing dashboard."""
by_unit: list[UsageAggregate] = field(default_factory=list)
by_employee: dict[str, float] = field(default_factory=dict)
by_project: dict[str, float] = field(default_factory=dict)
total_price: float = 0.0
total_cost: float = 0.0
@runtime_checkable
class AuditPort(Protocol):
async def record(self, *, event: AuditEvent) -> AuditEvent: ...
async def list_for_organization(
self, *, organization_id: str, limit: int = 100, offset: int = 0
) -> list[AuditEvent]: ...
@runtime_checkable
class UsageLedgerPort(Protocol):
async def record(self, *, usage: UsageRecord) -> None:
"""Append one usage event. Never raises for business reasons — a failed
ledger write is an infrastructure error and must surface loudly."""
...
async def breakdown(
self,
*,
organization_id: str,
since: datetime | None = None,
until: datetime | None = None,
) -> UsageBreakdown: ...
@@ -0,0 +1,55 @@
"""Domain router port — preview subdomains and the port ledger.
Security rules carried over from the current platform (see
``docs/current-infra-notes.md``) are enforced by the *adapters* as well as the
application layer:
* single-label hosts under ``PREVIEW_ROOT_DOMAIN`` only
* label charset ``[a-z0-9-]``, 3-40 chars
* proxy target must be ``localhost`` / ``127.0.0.1`` with a numeric port
* protected platform ports are never allocatable or proxied to
* the Caddy config is validated before it is reloaded, and rolled back on failure
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(slots=True, kw_only=True)
class PreviewDomainInfo:
host: str
port: int
url: str = ""
protected: bool = False
status: str = "active"
@dataclass(slots=True, kw_only=True)
class ReverseProxyResult:
ok: bool
message: str = ""
url: str = ""
@runtime_checkable
class DomainRouterPort(Protocol):
async def allocate_port(
self, *, organization_id: str, project_id: str, service_name: str
) -> int: ...
async def claim_preview_domain(
self,
*,
organization_id: str,
project_id: str,
requested_subdomain: str,
port: int,
) -> PreviewDomainInfo: ...
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult: ...
async def remove_preview_domain(self, *, domain_id: str) -> None: ...
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]: ...
@@ -0,0 +1,124 @@
"""Git provider port — repository lifecycle without leaking the admin token."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
@dataclass(slots=True, kw_only=True)
class RepoInfo:
owner: str
repo_name: str
clone_url: str = ""
web_url: str = ""
default_branch: str = "main"
private: bool = True
@dataclass(slots=True, kw_only=True)
class BranchInfo:
name: str
sha: str = ""
@dataclass(slots=True, kw_only=True)
class CommitInfo:
sha: str
branch: str
message: str = ""
files_changed: int = 0
@dataclass(slots=True, kw_only=True)
class PullRequestInfo:
number: int
url: str
state: str = "open"
@dataclass(slots=True, kw_only=True)
class MergeResult:
merged: bool
message: str = ""
@dataclass(slots=True, kw_only=True)
class RepoStatus:
exists: bool
private: bool = True
default_branch: str = "main"
branches: list[str] = field(default_factory=list)
open_pull_requests: int = 0
@dataclass(slots=True, kw_only=True)
class GitCredentials:
"""Returned only to the application layer. Never serialised to a response."""
username: str
password: str
@runtime_checkable
class GitProviderPort(Protocol):
"""Repository lifecycle.
Identity is passed in, never guessed. A real forge (Gitea) addresses
repositories as ``owner/repo``; the platform stores that pair in
``ProjectRepository``. Passing it down keeps the adapter stateless — it holds
no ``project_id`` mapping that would be lost on restart and would drift from
the database.
The mock adapter ignores ``owner``/``repo_name`` because it keys everything
by ``project_id``; the defaults keep that adapter valid.
"""
async def ensure_project_repo(
self, *, project_id: str, repo_name: str, description: str = ""
) -> RepoInfo: ...
async def ensure_credentials(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> GitCredentials: ...
async def create_branch(
self,
*,
project_id: str,
branch_name: str,
from_branch: str,
owner: str = "",
repo_name: str = "",
) -> BranchInfo: ...
async def commit_files(
self,
*,
project_id: str,
branch_name: str,
files: dict[str, str],
message: str,
owner: str = "",
repo_name: str = "",
) -> CommitInfo: ...
async def open_pull_request(
self,
*,
project_id: str,
head: str,
base: str,
title: str,
body: str = "",
owner: str = "",
repo_name: str = "",
) -> PullRequestInfo: ...
async def merge_pull_request(
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
) -> MergeResult: ...
async def get_repo_status(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> RepoStatus: ...
@@ -0,0 +1,57 @@
"""Model provider port — the only sanctioned way to reach an LLM.
The adapter decides which concrete provider/model is used; the caller passes an
``AiModel`` record and never a provider API key. That keeps model identity
inside the AI Control Plane.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from app.domain.ai_control.models import AiModel, ModelCredential
@dataclass(slots=True, kw_only=True)
class ChatMessage:
role: str # system | user | assistant | tool
content: str
@dataclass(slots=True, kw_only=True)
class ModelResponse:
content: str
model_internal_name: str = ""
input_tokens: int = 0
output_tokens: int = 0
latency_ms: int = 0
raw: dict = field(default_factory=dict)
@dataclass(slots=True, kw_only=True)
class ModelHealth:
ok: bool
detail: str = ""
@runtime_checkable
class ModelProviderPort(Protocol):
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
``OpenAICompatibleModelAdapter``."""
async def complete(
self,
*,
model: AiModel,
credential: ModelCredential | None,
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
"""Run one completion. Must raise ``ProviderUnavailableError`` on failure."""
...
async def health(self) -> ModelHealth:
...
@@ -0,0 +1,41 @@
"""Payment provider port — an adapter over Stripe (or mock).
The internal usage ledger is the source of truth; this port only moves money.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(slots=True, kw_only=True)
class CheckoutSession:
external_id: str
url: str
amount: float = 0.0
currency: str = "USD"
@dataclass(slots=True, kw_only=True)
class PaymentResult:
ok: bool
external_id: str = ""
message: str = ""
@runtime_checkable
class PaymentProviderPort(Protocol):
async def create_checkout(
self,
*,
organization_id: str,
amount: float,
currency: str,
success_url: str,
cancel_url: str,
) -> CheckoutSession: ...
async def refund(
self, *, external_payment_id: str, amount: float | None = None
) -> PaymentResult: ...
@@ -0,0 +1,75 @@
"""Sandbox provider port — the only path that may execute tenant code.
Implementations: ``LocalMockSandboxAdapter`` (default, executes nothing) and
``DaytonaSandboxAdapter`` / ``E2BSandboxAdapter`` behind ``SANDBOX_PROVIDER``.
Every method that runs something takes an explicit timeout. Callers must never
be able to request "no limit".
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
@dataclass(slots=True, kw_only=True)
class WorkspaceInfo:
external_id: str
status: str = "ready"
raw: dict = field(default_factory=dict)
@dataclass(slots=True, kw_only=True)
class SessionInfo:
external_id: str
status: str = "running"
raw: dict = field(default_factory=dict)
@dataclass(slots=True, kw_only=True)
class CommandResult:
exit_code: int
stdout: str = ""
stderr: str = ""
duration_ms: int = 0
truncated: bool = False
@dataclass(slots=True, kw_only=True)
class SnapshotInfo:
external_id: str
name: str = ""
@runtime_checkable
class SandboxProviderPort(Protocol):
async def create_workspace(
self, *, organization_id: str, project_id: str, template_id: str, image: str
) -> WorkspaceInfo: ...
async def start_session(self, *, workspace_external_id: str) -> SessionInfo: ...
async def run_command(
self,
*,
session_external_id: str,
command: str,
timeout_seconds: int,
max_output_bytes: int,
env: dict[str, str] | None = None,
) -> CommandResult: ...
async def read_file(self, *, session_external_id: str, path: str) -> str: ...
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None: ...
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo: ...
async def pause(self, *, session_external_id: str) -> None: ...
async def resume(self, *, session_external_id: str) -> SessionInfo: ...
async def destroy(self, *, session_external_id: str) -> None: ...
async def get_preview_url(self, *, session_external_id: str, port: int) -> str: ...
+68
View File
@@ -0,0 +1,68 @@
"""Storage, vector store and notification ports."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
@dataclass(slots=True, kw_only=True)
class StoredObject:
key: str
size_bytes: int = 0
url: str = ""
@runtime_checkable
class StoragePort(Protocol):
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject: ...
async def get(self, *, key: str) -> bytes: ...
async def delete(self, *, key: str) -> None: ...
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str: ...
@dataclass(slots=True, kw_only=True)
class VectorHit:
chunk_id: str
document_id: str
content: str
score: float = 0.0
metadata: dict = field(default_factory=dict)
@runtime_checkable
class VectorStorePort(Protocol):
async def upsert(
self,
*,
organization_id: str,
chunks: list[tuple[str, str, list[float]]],
metadata: dict | None = None,
) -> int: ...
async def search(
self,
*,
organization_id: str,
query: str,
limit: int = 5,
project_id: str | None = None,
) -> list[VectorHit]: ...
async def delete_document(self, *, document_id: str) -> None: ...
@dataclass(slots=True, kw_only=True)
class Notification:
kind: str
subject: str
body: str
to: str = ""
@runtime_checkable
class NotificationPort(Protocol):
async def send(self, *, notification: Notification) -> bool: ...
@@ -0,0 +1,37 @@
"""Audit helper used by the HTTP layer.
Every sensitive operation calls this. It is deliberately cheap (one INSERT) so
there is no excuse to skip it.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.audit.models import AuditEvent
from app.infrastructure.repositories.audit import AuditRepository
async def record_audit(
session: AsyncSession,
*,
action: str,
organization_id: str | None = None,
actor_user_id: str | None = None,
target_type: str = "",
target_id: str = "",
metadata: dict | None = None,
ip: str = "",
user_agent: str = "",
) -> AuditEvent:
event = AuditEvent(
action=action,
organization_id=organization_id,
actor_user_id=actor_user_id,
target_type=target_type,
target_id=target_id,
metadata_json=metadata or {},
ip_address=ip,
user_agent=user_agent,
)
return await AuditRepository(session).record(event=event)
@@ -0,0 +1,184 @@
"""Billing use cases.
Design rule, stated once and enforced here: **the internal usage ledger is the
source of truth.** A payment provider only moves money; it never decides what a
tenant owes. That is why ``summary`` computes totals from ``UsageLedgerRepository``
and not from any external invoice.
Credit is spent, not checked-then-spent: the debit is a single adjustment, so two
concurrent runs cannot both pass a stale affordability check.
"""
from __future__ import annotations
from datetime import datetime
from app.application.ports import PaymentProviderPort
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.domain.billing.models import (
CreditWallet,
Invoice,
Payment,
PaymentStatus,
Subscription,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import (
CreditWalletRepository,
InvoiceRepository,
PaymentRepository,
PlanRepository,
SubscriptionRepository,
)
#: A tenant may not buy an arbitrary amount of credit in one action.
MIN_TOPUP = 1.0
MAX_TOPUP = 10_000.0
class BillingService:
def __init__(
self,
*,
wallets: CreditWalletRepository,
invoices: InvoiceRepository,
payments: PaymentRepository,
plans: PlanRepository,
subscriptions: SubscriptionRepository,
ledger: UsageLedgerRepository,
payment_provider: PaymentProviderPort,
) -> None:
self.wallets = wallets
self.invoices = invoices
self.payments = payments
self.plans = plans
self.subscriptions = subscriptions
self.ledger = ledger
self.payment_provider = payment_provider
# -- reads ---------------------------------------------------------------
async def wallet_for(self, organization_id: str) -> CreditWallet:
return await self.wallets.ensure(organization_id)
async def summary(
self,
*,
organization_id: str,
since: datetime | None = None,
until: datetime | None = None,
) -> dict:
wallet = await self.wallets.ensure(organization_id)
breakdown = await self.ledger.breakdown(
organization_id=organization_id, since=since, until=until
)
return {
"balance": wallet.balance,
"currency": wallet.currency,
"low_balance": wallet.is_low,
"low_balance_threshold": wallet.low_balance_threshold,
"total_spend": breakdown.total_price,
"by_unit": breakdown.by_unit,
"by_employee": breakdown.by_employee,
"by_project": breakdown.by_project,
}
async def list_usage_events(
self, *, organization_id: str, limit: int = 100, offset: int = 0
) -> list:
return await self.ledger.list_events(
organization_id=organization_id, limit=limit, offset=offset
)
async def list_invoices(self, organization_id: str) -> list[Invoice]:
return await self.invoices.list_for_org(organization_id)
async def list_payments(self, organization_id: str) -> list[Payment]:
return await self.payments.list_for_org(organization_id)
async def current_subscription(self, organization_id: str) -> Subscription | None:
return await self.subscriptions.get_for_org(organization_id)
# -- writes --------------------------------------------------------------
async def add_credit(
self, *, organization_id: str, amount: float, allow_negative: bool = False
) -> CreditWallet:
"""Grant (or, for a platform owner, claw back) credit.
``allow_negative`` is reserved for the platform owner console; an org
admin may only ever add credit.
"""
if amount == 0:
raise ValidationError("مقدار اعتبار نمی‌تواند صفر باشد.")
if amount < 0 and not allow_negative:
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.")
if not allow_negative and amount > MAX_TOPUP:
raise ValidationError(f"حداکثر افزایش اعتبار در هر عملیات {MAX_TOPUP} است.")
wallet = await self.wallets.adjust(organization_id=organization_id, delta=amount)
if wallet.balance < 0:
# Refuse to leave a wallet in debt; undo and surface the conflict.
await self.wallets.adjust(organization_id=organization_id, delta=-amount)
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
return wallet
async def start_checkout(
self,
*,
organization_id: str,
amount: float,
success_url: str,
cancel_url: str,
) -> tuple[Payment, str]:
"""Create a pending payment plus the provider's hosted checkout URL."""
if amount < MIN_TOPUP:
raise ValidationError(f"حداقل مبلغ شارژ {MIN_TOPUP} است.")
if amount > MAX_TOPUP:
raise ValidationError(f"حداکثر مبلغ شارژ {MAX_TOPUP} است.")
wallet = await self.wallets.ensure(organization_id)
payment = await self.payments.create(
Payment(
organization_id=organization_id,
amount=amount,
status=PaymentStatus.PENDING,
provider="mock",
)
)
session = await self.payment_provider.create_checkout(
organization_id=organization_id,
amount=amount,
currency=wallet.currency,
success_url=success_url,
cancel_url=cancel_url,
)
updated = await self.payments.set_status(
payment.id, PaymentStatus.PENDING, external_id=session.external_id
)
return (updated or payment), session.url
async def settle_payment(
self, *, organization_id: str, payment_id: str, succeeded: bool
) -> Payment:
"""Mark a payment final and credit the wallet when it succeeded.
This is the only path that turns money into credit, so it is idempotent
with respect to status: a payment that is already final is returned
untouched rather than credited twice.
"""
payment = next(
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
None,
)
if payment is None:
raise NotFoundError("پرداخت یافت نشد.")
if payment.status != PaymentStatus.PENDING:
return payment
if succeeded:
await self.wallets.adjust(organization_id=organization_id, delta=payment.amount)
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
else:
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
return final or payment
@@ -0,0 +1,202 @@
"""Knowledge Base use cases: ingest text, chunk it, and retrieve it safely.
Two things matter here:
* **Chunking is deterministic.** The same text always produces the same chunks,
which makes ingestion idempotent-ish and the tests meaningful.
* **Retrieval is permission-aware.** Search is always scoped to one organization,
and optionally narrowed to one project. An org-wide source is visible to every
project; a project-scoped source is not visible from another project.
Embeddings are computed through ``VectorStorePort`` when a real adapter is
configured. With the mock adapter no vectors are produced and retrieval falls
back to keyword matching — the same fallback the plan calls for at MVP.
"""
from __future__ import annotations
from app.application.ports import VectorStorePort
from app.core.errors import NotFoundError, ValidationError
from app.domain.knowledge.models import (
Document,
DocumentChunk,
EmbeddingJob,
EmbeddingJobStatus,
KnowledgeSource,
RetrievalPolicy,
SourceKind,
)
from app.infrastructure.repositories.knowledge import (
DocumentChunkRepository,
DocumentRepository,
EmbeddingJobRepository,
KnowledgeSourceRepository,
RetrievalPolicyRepository,
)
#: Chunk size in words. Small enough to keep a retrieved passage focused.
CHUNK_WORDS = 180
CHUNK_OVERLAP_WORDS = 30
MAX_INGEST_CHARS = 400_000
def chunk_text(
text: str, *, size: int = CHUNK_WORDS, overlap: int = CHUNK_OVERLAP_WORDS
) -> list[str]:
"""Split text into overlapping word windows.
Pure and deterministic — no regex surprises, no dependence on punctuation.
"""
words = text.split()
if not words:
return []
if size <= 0:
raise ValueError("chunk size must be positive")
step = max(1, size - max(0, overlap))
chunks: list[str] = []
for start in range(0, len(words), step):
window = words[start : start + size]
if not window:
break
chunks.append(" ".join(window))
if start + size >= len(words):
break
return chunks
class KnowledgeService:
def __init__(
self,
*,
sources: KnowledgeSourceRepository,
documents: DocumentRepository,
chunks: DocumentChunkRepository,
jobs: EmbeddingJobRepository,
policies: RetrievalPolicyRepository,
vector_store: VectorStorePort,
) -> None:
self.sources = sources
self.documents = documents
self.chunks = chunks
self.jobs = jobs
self.policies = policies
self.vector_store = vector_store
# -- ingestion -----------------------------------------------------------
async def create_text_source(
self,
*,
organization_id: str,
name: str,
content: str,
project_id: str | None = None,
title: str = "",
) -> tuple[KnowledgeSource, int]:
if not name.strip():
raise ValidationError("نام منبع دانش نمی‌تواند خالی باشد.")
if not content.strip():
raise ValidationError("محتوای منبع دانش نمی‌تواند خالی باشد.")
if len(content) > MAX_INGEST_CHARS:
raise ValidationError("حجم محتوا بیش از حد مجاز است.")
source = await self.sources.create(
KnowledgeSource(
organization_id=organization_id,
name=name.strip(),
kind=SourceKind.TEXT,
project_id=project_id,
)
)
document = await self.documents.create(
Document(source_id=source.id, title=title.strip() or source.name, content=content)
)
pieces = chunk_text(content)
chunk_rows = [
DocumentChunk(document_id=document.id, index=index, content=piece)
for index, piece in enumerate(pieces)
]
created = await self.chunks.create_many(chunk_rows)
job = await self.jobs.create(EmbeddingJob(source_id=source.id))
try:
# The mock vector store is a no-op that accepts and discards; a real
# adapter embeds here. Keyword search below works either way.
await self.vector_store.upsert(
organization_id=organization_id,
chunks=[(chunk.id, chunk.content, []) for chunk in chunk_rows],
metadata={"source_id": source.id, "project_id": project_id},
)
await self.jobs.set_status(job.id, EmbeddingJobStatus.DONE)
except Exception as exc: # noqa: BLE001 - embedding is best-effort at MVP
# Ingestion still succeeded; only the vector index is degraded, and
# keyword search keeps working. Record why rather than failing hard.
await self.jobs.set_status(job.id, EmbeddingJobStatus.FAILED, error=str(exc))
return source, created
# -- reads ---------------------------------------------------------------
async def list_sources(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[KnowledgeSource]:
return await self.sources.list_for_org(organization_id, limit=limit, offset=offset)
async def get_source_or_404(
self, *, organization_id: str, source_id: str
) -> KnowledgeSource:
source = await self.sources.get_for_org(organization_id, source_id)
if source is None:
raise NotFoundError("منبع دانش یافت نشد.")
return source
async def source_detail(self, *, source_id: str) -> dict:
documents = await self.documents.list_for_source(source_id)
return {
"documents": documents,
"chunk_count": await self.chunks.count_for_source(source_id),
"job": await self.jobs.latest_for_source(source_id),
}
async def chunk_counts(self, source_ids: list[str]) -> dict[str, int]:
"""Chunk counts for many sources at once — see ``count_for_sources``."""
return await self.chunks.count_for_sources(source_ids)
async def search(
self,
*,
organization_id: str,
query: str,
project_id: str | None = None,
limit: int = 5,
) -> list[dict]:
return await self.chunks.keyword_search(
organization_id=organization_id,
query=query,
project_id=project_id,
limit=limit,
)
async def delete_source(self, *, organization_id: str, source_id: str) -> None:
source = await self.get_source_or_404(
organization_id=organization_id, source_id=source_id
)
await self.sources.delete(source.id)
# -- retrieval policies --------------------------------------------------
async def list_policies(self, organization_id: str) -> list[RetrievalPolicy]:
return await self.policies.list_for_org(organization_id)
async def create_policy(
self, *, organization_id: str, name: str, scope: dict | None = None
) -> RetrievalPolicy:
if not name.strip():
raise ValidationError("نام سیاست بازیابی نمی‌تواند خالی باشد.")
return await self.policies.create(
RetrievalPolicy(
organization_id=organization_id, name=name.strip(), scope_json=dict(scope or {})
)
)
@@ -0,0 +1,243 @@
"""Project and preview-domain use cases.
This is the application layer: it orchestrates domain rules, repositories and the
driven ports (Git, domain router). It knows nothing about HTTP.
Security properties enforced here (not in the router, not only in the adapter):
* a project repository is always created **private**
* the repository name is collision-free across tenants
* the per-project Git password is encrypted before it is stored and never
returned to a caller
* a preview host must be a single label under the configured root domain
* a preview host is unique platform-wide
* the proxy target port must be allocatable and must not be a protected
platform port
* a project may hold at most ``max_subdomains_per_project`` hosts
"""
from __future__ import annotations
import hashlib
from app.application.ports import (
DomainRouterPort,
GitCredentials,
GitProviderPort,
RepoInfo,
RepoStatus,
)
from app.core.config import settings
from app.core.crypto import decrypt, encrypt
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.domain.base import ensure_unique_slug, slugify
from app.domain.platform.rules import (
assert_not_protected_port,
assert_port_allocatable,
build_host,
validate_subdomain_label,
)
from app.domain.projects.models import (
Project,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.repositories.projects import (
PreviewDomainRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
class ProjectService:
def __init__(
self,
*,
projects: ProjectRepository,
repos: ProjectGitRepoRepository,
credentials: ProjectGitCredentialRepository,
domains: PreviewDomainRepository,
git: GitProviderPort,
router: DomainRouterPort,
) -> None:
self.projects = projects
self.repos = repos
self.credentials = credentials
self.domains = domains
self.git = git
self.router = router
# -- projects ------------------------------------------------------------
async def create_project(
self,
*,
organization_id: str,
organization_slug: str,
created_by_user_id: str,
name: str,
description: str = "",
) -> Project:
slug = ensure_unique_slug(slugify(name), await self.projects.list_slugs(organization_id))
return await self.projects.create(
Project(
organization_id=organization_id,
name=name.strip(),
slug=slug,
description=description,
created_by_user_id=created_by_user_id,
)
)
async def get_or_404(self, *, organization_id: str, project_id: str) -> Project:
project = await self.projects.get(project_id)
# Returning 404 (not 403) keeps the existence of other tenants' projects
# out of the response.
if project is None or project.organization_id != organization_id:
raise NotFoundError("پروژه یافت نشد.")
return project
@staticmethod
def repo_name_for(*, organization_slug: str, project: Project) -> str:
"""``orgslug-projectslug-shortid`` — stable and collision-free."""
digest = hashlib.sha1(f"{organization_slug}:{project.id}".encode()).hexdigest()[:6]
raw = f"{organization_slug}-{project.slug}-{digest}"
return raw[:100].strip("-")
# -- git -----------------------------------------------------------------
async def ensure_repository(
self, *, organization_slug: str, project: Project
) -> ProjectGitRepo:
repo_name = self.repo_name_for(organization_slug=organization_slug, project=project)
info: RepoInfo = await self.git.ensure_project_repo(
project_id=project.id,
repo_name=repo_name,
description=f"مخزن پروژهٔ {project.name}",
)
credentials: GitCredentials = await self.git.ensure_credentials(
project_id=project.id, owner=info.owner, repo_name=info.repo_name
)
await self.credentials.upsert(
ProjectGitCredential(
project_id=project.id,
username=credentials.username,
# Encrypted at rest. The plaintext only exists inside this method.
encrypted_password=encrypt(credentials.password),
)
)
return await self.repos.upsert(
ProjectGitRepo(
project_id=project.id,
provider=settings.git_provider,
owner=info.owner,
repo_name=info.repo_name,
clone_url=info.clone_url,
web_url=info.web_url,
default_branch=info.default_branch,
)
)
async def repo_status(self, *, project: Project) -> RepoStatus:
record = await self.repos.get_for_project(project.id)
if record is None:
return RepoStatus(exists=False)
# Identity comes from our own record so the adapter stays stateless.
return await self.git.get_repo_status(
project_id=project.id, owner=record.owner, repo_name=record.repo_name
)
async def clone_credentials(self, *, project: Project) -> tuple[str, str]:
"""``(username, password)`` for a one-off clone.
Only the application layer may call this, and the router must never
serialise the result.
"""
record = await self.credentials.get_for_project(project.id)
if record is None:
raise NotFoundError("اطلاعات دسترسی مخزن ثبت نشده است.")
return record.username, decrypt(record.encrypted_password)
# -- preview domains -----------------------------------------------------
async def allocate_port(self, *, project: Project, service_name: str = "web") -> int:
existing = await self.domains.get_port(project.id, service_name)
if existing is not None:
return existing
start, end = settings.preview_port_start, settings.preview_port_end
used = await self.domains.list_used_ports(start=start, end=end)
for port in range(start, end + 1):
if port in used:
continue
try:
assert_port_allocatable(port, start=start, end=end)
except ValueError:
continue
await self.domains.add_port(
organization_id=project.organization_id,
project_id=project.id,
service_name=service_name,
port=port,
)
return port
raise ConflictError("پورت آزادی در بازهٔ تعیین‌شده وجود ندارد.")
async def create_preview_domain(
self,
*,
project: Project,
requested_subdomain: str,
created_by_user_id: str,
port: int | None = None,
) -> dict:
label = validate_subdomain_label(requested_subdomain)
host = build_host(label, settings.preview_root_domain)
if await self.domains.get_by_host(host) is not None:
raise ConflictError(f"دامنهٔ {host} قبلاً ثبت شده است.")
count = await self.domains.count_for_project(project.id)
if count >= settings.max_subdomains_per_project:
raise ConflictError(
f"هر پروژه حداکثر {settings.max_subdomains_per_project} دامنه می‌تواند داشته باشد."
)
target_port = port if port is not None else await self.allocate_port(project=project)
# A tenant must never be able to expose an internal platform service.
assert_not_protected_port(target_port)
result = await self.router.configure_reverse_proxy(host=host, port=target_port)
if not result.ok:
raise ValidationError(result.message or "پیکربندی دامنه ناموفق بود.")
record = await self.domains.create(
organization_id=project.organization_id,
project_id=project.id,
host=host,
port=target_port,
created_by_user_id=created_by_user_id,
)
record["url"] = result.url or f"https://{host}"
return record
async def remove_preview_domain(self, *, project: Project, domain_id: str) -> None:
record = await self.domains.get(domain_id)
if record is None or record["project_id"] != project.id:
raise NotFoundError("دامنه یافت نشد.")
if record["protected"]:
raise PermissionDeniedError(
"این دامنه محافظت‌شده است و فقط مالک سامانه می‌تواند آن را تغییر دهد."
)
await self.router.remove_preview_domain(domain_id=record["host"])
await self.domains.delete(domain_id)
async def list_preview_domains(self, *, project: Project) -> list[dict]:
records = await self.domains.list_for_project(project.id)
for record in records:
record["url"] = f"https://{record['host']}"
return records
@@ -0,0 +1,454 @@
"""Sandbox Gateway use cases.
The security posture is the whole point of this module:
* **No host execution path.** The only way to run anything is through
``SandboxProviderPort``. There is no fallback that shells out locally.
* **Limits come from policy, never from the caller.** A request cannot ask for a
longer timeout, more memory, or a bigger output cap than its scope allows.
* **Egress is deny-by-default.** The stored ``network_policy`` defaults to
``deny_all``; nothing here opens it implicitly.
* **Every command is metered.** CPU seconds are written to the usage ledger, so
sandbox time is billable and auditable.
"""
from __future__ import annotations
import contextlib
from datetime import datetime
from app.application.ports import CommandResult, SandboxProviderPort, UsageRecord
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError, ProviderUnavailableError, ValidationError
from app.domain.ai_control.models import RateUnit
from app.domain.base import utcnow
from app.domain.sandbox.models import (
CommandRun,
NetworkPolicy,
PreviewUrl,
SandboxProviderKind,
SandboxResourceLimit,
SandboxSession,
SessionStatus,
Snapshot,
Workspace,
WorkspaceStatus,
)
from app.infrastructure.repositories.ai_control import RateCardRepository
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.sandbox import (
CommandRunRepository,
PreviewUrlRepository,
ResourceLimitRepository,
SandboxSessionRepository,
SandboxTemplateRepository,
SnapshotRepository,
WorkspaceRepository,
)
#: Hard ceiling, independent of policy. A policy may be lower, never higher.
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
MAX_COMMAND_LENGTH = 4_000
class SandboxService:
def __init__(
self,
*,
templates: SandboxTemplateRepository,
workspaces: WorkspaceRepository,
sessions: SandboxSessionRepository,
commands: CommandRunRepository,
snapshots: SnapshotRepository,
previews: PreviewUrlRepository,
limits: ResourceLimitRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
projects: ProjectRepository,
provider: SandboxProviderPort,
) -> None:
self.templates = templates
self.workspaces = workspaces
self.sessions = sessions
self.commands = commands
self.snapshots = snapshots
self.previews = previews
self.limits = limits
self.rate_cards = rate_cards
self.ledger = ledger
self.projects = projects
self.provider = provider
# -- limits --------------------------------------------------------------
async def effective_limits(
self, *, organization_id: str, project_id: str | None = None
) -> SandboxResourceLimit:
"""Resolve the strictest applicable policy.
Project scope wins over organization scope; the absolute ceiling always
wins over both.
"""
if project_id:
scoped = await self.limits.get_for_scope("project", project_id)
if scoped is not None:
return self._clamp(scoped)
org_scoped = await self.limits.get_for_scope("organization", organization_id)
if org_scoped is not None:
return self._clamp(org_scoped)
return self._clamp(
SandboxResourceLimit(
scope="platform",
scope_id=None,
timeout_seconds=settings.sandbox_command_timeout_seconds,
max_output_bytes=settings.sandbox_max_output_bytes,
)
)
@staticmethod
def _clamp(limit: SandboxResourceLimit) -> SandboxResourceLimit:
limit.timeout_seconds = max(
1, min(int(limit.timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS)
)
limit.max_output_bytes = max(
1024, min(int(limit.max_output_bytes), ABSOLUTE_MAX_OUTPUT_BYTES)
)
limit.memory_mb = max(128, int(limit.memory_mb))
limit.disk_mb = max(256, int(limit.disk_mb))
return limit
# -- templates -----------------------------------------------------------
async def list_templates(self):
return await self.templates.list()
# -- workspaces ----------------------------------------------------------
async def create_workspace(
self, *, organization_id: str, project_id: str, template_id: str
) -> Workspace:
template = await self.templates.get(template_id)
if template is None:
raise NotFoundError("قالب سندباکس یافت نشد.")
provider_kind = str(template.provider) or SandboxProviderKind.MOCK
try:
info = await self.provider.create_workspace(
organization_id=organization_id,
project_id=project_id,
template_id=template_id,
image=template.image,
)
except Exception as exc: # noqa: BLE001 - surface as a provider outage
raise ProviderUnavailableError(
"ساخت محیط اجرای ایزوله ناموفق بود."
) from exc
workspace = await self.workspaces.create(
Workspace(
organization_id=organization_id,
project_id=project_id,
template_id=template_id,
provider=provider_kind,
external_workspace_id=info.external_id,
status=WorkspaceStatus.READY,
)
)
return workspace
async def list_workspaces(
self,
*,
organization_id: str,
project_id: str,
limit: int = 100,
offset: int = 0,
) -> list[Workspace]:
"""Workspaces for a project the caller's tenant actually owns."""
project = await self.projects.get(project_id)
# 404 rather than an empty list, so a foreign project id is
# indistinguishable from a non-existent one.
if project is None or project.organization_id != organization_id:
raise NotFoundError("پروژه یافت نشد.")
return await self.workspaces.list_for_project(
project_id, limit=limit, offset=offset
)
async def get_workspace_or_404(
self, *, organization_id: str, workspace_id: str
) -> Workspace:
workspace = await self.workspaces.get_for_org(organization_id, workspace_id)
if workspace is None:
raise NotFoundError("محیط اجرا یافت نشد.")
return workspace
# -- sessions ------------------------------------------------------------
async def start_session(
self, *, organization_id: str, workspace_id: str
) -> SandboxSession:
workspace = await self.get_workspace_or_404(
organization_id=organization_id, workspace_id=workspace_id
)
if workspace.status == WorkspaceStatus.DESTROYED:
raise ConflictError("این محیط اجرا از بین رفته است.")
if not workspace.external_workspace_id:
raise ProviderUnavailableError("محیط اجرا در ارائه‌دهنده ثبت نشده است.")
info = await self.provider.start_session(
workspace_external_id=workspace.external_workspace_id
)
session = await self.sessions.create(
SandboxSession(
workspace_id=workspace.id,
external_session_id=info.external_id,
status=SessionStatus.RUNNING,
)
)
await self.workspaces.set_status(workspace.id, WorkspaceStatus.READY)
return session
async def get_session_or_404(
self, *, organization_id: str, session_id: str
) -> SandboxSession:
session = await self.sessions.get_for_org(organization_id, session_id)
if session is None:
raise NotFoundError("نشست اجرا یافت نشد.")
return session
async def stop_session(self, *, organization_id: str, session_id: str) -> SandboxSession:
session = await self.get_session_or_404(
organization_id=organization_id, session_id=session_id
)
if not session.external_session_id:
raise ProviderUnavailableError("نشست در ارائه‌دهنده ثبت نشده است.")
await self.provider.destroy(session_external_id=session.external_session_id)
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
refreshed = await self.sessions.get(session.id)
return refreshed or session
# -- commands ------------------------------------------------------------
async def run_command(
self,
*,
organization_id: str,
session_id: str,
command: str,
user_id: str | None = None,
) -> tuple[CommandRun, CommandResult]:
session = await self.get_session_or_404(
organization_id=organization_id, session_id=session_id
)
if not session.external_session_id:
raise ProviderUnavailableError("نشست در ارائه‌دهنده ثبت نشده است.")
if session.status != SessionStatus.RUNNING:
raise ConflictError("نشست در وضعیت اجرا نیست.")
cleaned = command.strip()
if not cleaned:
raise ValidationError("دستور نمی‌تواند خالی باشد.")
if len(cleaned) > MAX_COMMAND_LENGTH:
raise ValidationError("دستور بیش از حد طولانی است.")
workspace = await self.workspaces.get_for_org(
organization_id, session.workspace_id
)
limits = await self.effective_limits(
organization_id=organization_id,
project_id=workspace.project_id if workspace else None,
)
result = await self.provider.run_command(
session_external_id=session.external_session_id,
command=cleaned,
# Timeout and output cap are decided here, not by the caller.
timeout_seconds=limits.timeout_seconds,
max_output_bytes=limits.max_output_bytes,
env={},
)
run = await self.commands.create(
CommandRun(
session_id=session.id,
command=cleaned,
exit_code=result.exit_code,
stdout_ref=result.stdout[: limits.max_output_bytes],
stderr_ref=result.stderr[: limits.max_output_bytes],
duration_ms=result.duration_ms,
truncated=result.truncated,
)
)
await self.sessions.set_status(session.id, SessionStatus.RUNNING)
# CPU seconds are billed. 1 second of wall time ~= 1 CPU second at MVP.
cpu_seconds = max(0.0, result.duration_ms / 1000.0)
if cpu_seconds > 0:
card = await self.rate_cards.get_by_unit(RateUnit.SANDBOX_CPU_SECOND)
unit_price = card.customer_price if card else 0.0
internal_cost = card.internal_cost_estimate if card else 0.0
await self.ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=workspace.project_id if workspace else None,
user_id=user_id,
unit=RateUnit.SANDBOX_CPU_SECOND,
quantity=cpu_seconds,
unit_price=unit_price,
internal_cost_estimate=cpu_seconds * internal_cost,
total_price=cpu_seconds * unit_price,
metadata_json={"session_id": session.id, "exit_code": result.exit_code},
)
)
return run, result
async def list_commands(self, *, session_id: str) -> list[CommandRun]:
return await self.commands.list_for_session(session_id)
# -- files ---------------------------------------------------------------
async def read_file(
self, *, organization_id: str, session_id: str, path: str
) -> str:
session = await self.get_session_or_404(
organization_id=organization_id, session_id=session_id
)
if not session.external_session_id:
raise ProviderUnavailableError("نشست در ارائه‌دهنده ثبت نشده است.")
return await self.provider.read_file(
session_external_id=session.external_session_id, path=path
)
async def write_file(
self, *, organization_id: str, session_id: str, path: str, content: str
) -> None:
session = await self.get_session_or_404(
organization_id=organization_id, session_id=session_id
)
if not session.external_session_id:
raise ProviderUnavailableError("نشست در ارائه‌دهنده ثبت نشده است.")
await self.provider.write_file(
session_external_id=session.external_session_id, path=path, content=content
)
# -- snapshots & previews ------------------------------------------------
async def snapshot(
self, *, organization_id: str, session_id: str, name: str
) -> Snapshot:
session = await self.get_session_or_404(
organization_id=organization_id, session_id=session_id
)
if not session.external_session_id:
raise ProviderUnavailableError("نشست در ارائه‌دهنده ثبت نشده است.")
info = await self.provider.snapshot(
session_external_id=session.external_session_id, name=name
)
return await self.snapshots.create(
Snapshot(
workspace_id=session.workspace_id, name=name,
external_snapshot_id=info.external_id,
)
)
async def get_preview_url(
self, *, organization_id: str, session_id: str, port: int
) -> PreviewUrl:
session = await self.get_session_or_404(
organization_id=organization_id, session_id=session_id
)
if not session.external_session_id:
raise ProviderUnavailableError("نشست در ارائه‌دهنده ثبت نشده است.")
url = await self.provider.get_preview_url(
session_external_id=session.external_session_id, port=port
)
return await self.previews.create(
PreviewUrl(workspace_id=session.workspace_id, port=port, url=url)
)
# -- policy administration ----------------------------------------------
async def set_limits(
self,
*,
scope: str,
scope_id: str | None,
cpu: float,
memory_mb: int,
disk_mb: int,
timeout_seconds: int,
max_output_bytes: int,
network_policy: NetworkPolicy,
) -> SandboxResourceLimit:
if scope not in ("organization", "project", "platform"):
raise ValidationError("دامنهٔ محدودیت نامعتبر است.")
return await self.limits.upsert(
self._clamp(
SandboxResourceLimit(
scope=scope, scope_id=scope_id, cpu=cpu, memory_mb=memory_mb,
disk_mb=disk_mb, timeout_seconds=timeout_seconds,
max_output_bytes=max_output_bytes, network_policy=network_policy,
)
)
)
async def destroy_workspace(
self, *, organization_id: str, workspace_id: str
) -> Workspace:
workspace = await self.get_workspace_or_404(
organization_id=organization_id, workspace_id=workspace_id
)
for session in await self.sessions.list_for_workspace(workspace.id):
if session.external_session_id:
# Teardown is best-effort: a remote sandbox that is already gone
# must not block us from marking the local row stopped.
with contextlib.suppress(Exception):
await self.provider.destroy(
session_external_id=session.external_session_id
)
await self.sessions.set_status(
session.id, SessionStatus.STOPPED, stopped=True
)
await self.workspaces.set_status(workspace.id, WorkspaceStatus.DESTROYED)
refreshed = await self.workspaces.get_for_org(organization_id, workspace_id)
return refreshed or workspace
@staticmethod
def now():
return utcnow()
# -- platform maintenance ------------------------------------------------
async def reap_idle_sessions(
self, *, idle_before: datetime, limit: int = 200
) -> list[SandboxSession]:
"""Stop sandbox sessions untouched since ``idle_before``.
**Deliberately not tenant-scoped.** This is the gateway's own housekeeping
and spans every organization, so it is never reachable from a
tenant-scoped route — only from the sandbox-gateway worker. Callers are
responsible for the audit trail.
A provider that has already forgotten the session must not keep it
"active" in our own record forever, so the remote teardown is
best-effort and the local row is always marked stopped.
"""
stopped: list[SandboxSession] = []
stale = await self.sessions.list_stale_active(
idle_before=idle_before, limit=limit
)
for session in stale:
if session.external_session_id:
with contextlib.suppress(Exception):
await self.provider.destroy(
session_external_id=session.external_session_id
)
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
refreshed = await self.sessions.get(session.id)
stopped.append(refreshed or session)
return stopped
@@ -0,0 +1,794 @@
"""AI Workforce use cases: employees, Manager Chat, tasks and the run engine.
This module is the application layer. It owns three invariants that the rest of
the system depends on:
1. **A tenant never sees model identity.** The service resolves a routing policy
to a concrete ``AiModel`` internally and passes it to the provider port. No
router may serialise that name back to a customer.
2. **Every model call writes a usage event.** Billing reads the internal ledger,
never the provider's invoice.
3. **Every run leaves a timeline.** ``run_steps`` is the observable record of
what happened, and it is what the UI renders.
The MVP "engine" is deliberately synchronous and deterministic: it drives a run
through its real state machine (``RUN_TRANSITIONS``) and records each transition
as a step. Swapping in a durable worker only changes *where* the transitions are
executed, not the transitions themselves.
"""
from __future__ import annotations
from app.application.ports import (
ChatMessage,
ModelProviderPort,
UsageRecord,
)
from app.core.errors import (
ConflictError,
NotFoundError,
PermissionDeniedError,
ProviderUnavailableError,
ValidationError,
)
from app.domain.ai_control.models import RateUnit, RoutingMode
from app.domain.workforce.models import (
TERMINAL_RUN_STATUSES,
AgentTemplate,
Artifact,
Employee,
EmployeeStatus,
EmployeeToolPermission,
Message,
MessageRole,
PromptVersion,
RoleKey,
Run,
RunStatus,
RunStep,
Task,
TaskStatus,
Thread,
ToolCall,
ToolCallStatus,
can_transition_run,
)
from app.domain.workforce.tools import (
TOOL_CATALOG,
ToolSpec,
is_known_tool,
tool_requires_approval,
)
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
ArtifactRepository,
EmployeeRepository,
EmployeeToolPermissionRepository,
MessageRepository,
PromptVersionRepository,
RunRepository,
RunStepRepository,
TaskRepository,
ThreadRepository,
ToolCallRepository,
)
#: Fallback price when the platform owner has not configured a rate card yet.
#: Charging zero silently would hide a misconfiguration, so we use a tiny
#: non-zero placeholder and mark it in the usage metadata.
_UNPRICED = 0.0000001
_SYSTEM_PROMPT = (
"تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. "
"هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن."
)
class WorkforceService:
def __init__(
self,
*,
employees: EmployeeRepository,
templates: AgentTemplateRepository,
prompts: PromptVersionRepository,
permissions: EmployeeToolPermissionRepository,
threads: ThreadRepository,
messages: MessageRepository,
tasks: TaskRepository,
runs: RunRepository,
run_steps: RunStepRepository,
tool_calls: ToolCallRepository,
artifacts: ArtifactRepository,
models: AiModelRepository,
credentials: ModelCredentialRepository,
routing_policies: RoutingPolicyRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
provider: ModelProviderPort,
) -> None:
self.employees = employees
self.templates = templates
self.prompts = prompts
self.permissions = permissions
self.threads = threads
self.messages = messages
self.tasks = tasks
self.runs = runs
self.run_steps = run_steps
self.tool_calls = tool_calls
self.artifacts = artifacts
self.models = models
self.credentials = credentials
self.routing_policies = routing_policies
self.rate_cards = rate_cards
self.ledger = ledger
self.provider = provider
# ------------------------------------------------------------------ #
# Templates #
# ------------------------------------------------------------------ #
async def list_templates(self) -> list[AgentTemplate]:
return await self.templates.list()
async def list_routing_choices(self) -> list:
"""Active routing policies, for customer-facing selection.
Callers must render only ``name``/``description`` — ``mode`` is a routing
hint that should not reach a tenant.
"""
return [p for p in await self.routing_policies.list() if p.is_active]
# ------------------------------------------------------------------ #
# Employees #
# ------------------------------------------------------------------ #
async def create_employee(
self,
*,
organization_id: str,
created_by_user_id: str,
name: str,
role_key: RoleKey,
template_id: str | None = None,
project_id: str | None = None,
routing_policy_id: str | None = None,
) -> Employee:
template: AgentTemplate | None = None
if template_id:
template = await self.templates.get(template_id)
if template is None:
raise NotFoundError("قالب کارمند یافت نشد.")
employee = await self.employees.create(
Employee(
organization_id=organization_id,
name=name.strip(),
role_key=role_key,
template_id=template_id,
project_id=project_id,
routing_policy_id=routing_policy_id
or (template.default_routing_policy_id if template else None),
)
)
# Seed version 1 of the prompt from the template, so the employee is
# usable immediately and every later edit is a tracked revision.
if template is not None and template.default_prompt:
await self.prompts.create(
PromptVersion(
employee_id=employee.id,
version=1,
content=template.default_prompt,
created_by_user_id=created_by_user_id,
)
)
if template is not None:
for tool_key in template.default_tools_json:
await self.permissions.set_permission(
employee_id=employee.id, tool_key=tool_key, allowed=True
)
return employee
async def list_employees(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Employee]:
return await self.employees.list_for_org(organization_id, limit=limit, offset=offset)
async def get_employee_or_404(
self, *, organization_id: str, employee_id: str
) -> Employee:
employee = await self.employees.get_for_org(organization_id, employee_id)
# 404 rather than 403: another tenant's id must be indistinguishable
# from a non-existent one.
if employee is None:
raise NotFoundError("کارمند یافت نشد.")
return employee
async def update_employee(
self,
*,
organization_id: str,
employee_id: str,
name: str | None = None,
status: EmployeeStatus | None = None,
project_id: str | None = None,
routing_policy_id: str | None = None,
) -> Employee:
employee = await self.get_employee_or_404(
organization_id=organization_id, employee_id=employee_id
)
if name is not None:
employee.name = name.strip()
if status is not None:
employee.status = status
if project_id is not None:
employee.project_id = project_id or None
if routing_policy_id is not None:
employee.routing_policy_id = routing_policy_id or None
return await self.employees.update(employee)
async def list_prompt_versions(self, employee_id: str) -> list[PromptVersion]:
return await self.prompts.list_for_employee(employee_id)
async def add_prompt_version(
self, *, employee_id: str, content: str, created_by_user_id: str
) -> PromptVersion:
if not content.strip():
raise ValidationError("متن پرامپت نمی‌تواند خالی باشد.")
return await self.prompts.create(
PromptVersion(
employee_id=employee_id,
version=await self.prompts.next_version_number(employee_id),
content=content,
created_by_user_id=created_by_user_id,
)
)
async def list_tool_permissions(self, employee_id: str) -> list[EmployeeToolPermission]:
return await self.permissions.list_for_employee(employee_id)
# ------------------------------------------------------------------ #
# Threads & messages #
# ------------------------------------------------------------------ #
async def create_thread(
self,
*,
organization_id: str,
created_by_user_id: str,
title: str = "",
project_id: str | None = None,
) -> Thread:
return await self.threads.create(
Thread(
organization_id=organization_id,
title=title.strip(),
project_id=project_id,
created_by_user_id=created_by_user_id,
)
)
async def list_threads(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Thread]:
return await self.threads.list_for_org(organization_id, limit=limit, offset=offset)
async def get_thread_or_404(self, *, organization_id: str, thread_id: str) -> Thread:
thread = await self.threads.get_for_org(organization_id, thread_id)
if thread is None:
raise NotFoundError("گفتگو یافت نشد.")
return thread
async def list_messages(self, thread_id: str) -> list[Message]:
return await self.messages.list_for_thread(thread_id)
async def post_user_message(
self,
*,
organization_id: str,
thread_id: str,
content: str,
user_id: str,
employee_id: str | None = None,
) -> tuple[Message, Message]:
"""Append the user's message, then produce the assistant reply.
Returns ``(user_message, assistant_message)`` so the caller can render
both without a second round-trip.
"""
thread = await self.get_thread_or_404(
organization_id=organization_id, thread_id=thread_id
)
if not content.strip():
raise ValidationError("متن پیام نمی‌تواند خالی باشد.")
user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
)
employee: Employee | None = None
if employee_id:
employee = await self.get_employee_or_404(
organization_id=organization_id, employee_id=employee_id
)
else:
employee = await self._default_manager(organization_id)
history = await self.messages.list_for_thread(thread.id)
system_prompt = await self._system_prompt_for(employee)
reply = await self._complete(
organization_id=organization_id,
employee=employee,
project_id=thread.project_id,
user_id=user_id,
system=system_prompt,
messages=[
ChatMessage(role=m.role.value, content=m.content)
for m in history
if m.role in (MessageRole.USER, MessageRole.ASSISTANT)
],
)
assistant_message = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=reply,
employee_id=employee.id if employee else None,
)
)
return user_message, assistant_message
# ------------------------------------------------------------------ #
# Tasks #
# ------------------------------------------------------------------ #
async def create_task(
self,
*,
organization_id: str,
created_by_user_id: str,
title: str,
description: str = "",
project_id: str | None = None,
thread_id: str | None = None,
assigned_employee_id: str | None = None,
) -> Task:
if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
if assigned_employee_id:
# Validates tenant ownership before we ever store the reference.
await self.get_employee_or_404(
organization_id=organization_id, employee_id=assigned_employee_id
)
return await self.tasks.create(
Task(
organization_id=organization_id,
title=title.strip(),
description=description,
project_id=project_id,
thread_id=thread_id,
assigned_employee_id=assigned_employee_id,
created_by_user_id=created_by_user_id,
)
)
async def list_tasks(
self,
organization_id: str,
*,
status: TaskStatus | None = None,
limit: int = 100,
offset: int = 0,
) -> list[Task]:
return await self.tasks.list_for_org(
organization_id, status=status, limit=limit, offset=offset
)
async def get_task_or_404(self, *, organization_id: str, task_id: str) -> Task:
task = await self.tasks.get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
return task
async def list_task_runs(self, task_id: str) -> list[Run]:
return await self.runs.list_for_task(task_id)
# ------------------------------------------------------------------ #
# Runs #
# ------------------------------------------------------------------ #
async def start_run(
self,
*,
organization_id: str,
task_id: str,
user_id: str,
employee_id: str | None = None,
) -> Run:
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
raise ConflictError("این کار قبلاً بسته شده است.")
employee: Employee | None = None
if employee_id or task.assigned_employee_id:
employee = await self.get_employee_or_404(
organization_id=organization_id,
employee_id=employee_id or task.assigned_employee_id or "",
)
if not employee.is_available:
raise ConflictError("این کارمند در حالت فعال نیست.")
else:
employee = await self._default_manager(organization_id)
run = await self.runs.create(
Run(task_id=task.id, employee_id=employee.id if employee else None)
)
await self.tasks.set_status(task.id, TaskStatus.RUNNING)
await self.run_steps.append(
run_id=run.id, kind="queued", title="کار در صف اجرا قرار گرفت.",
payload={"task_id": task.id},
)
try:
run = await self._advance(run, RunStatus.PLANNING)
await self.run_steps.append(
run_id=run.id, kind="planning", title="برنامه‌ریزی مرحلهٔ اجرا."
)
run = await self._advance(run, RunStatus.RUNNING)
await self.run_steps.append(
run_id=run.id, kind="running", title="اجرای کار آغاز شد."
)
reply = await self._complete(
organization_id=organization_id,
employee=employee,
project_id=task.project_id,
user_id=user_id,
run_id=run.id,
system=await self._system_prompt_for(employee),
messages=[
ChatMessage(role="user", content=self._task_brief(task)),
],
)
await self.run_steps.append(
run_id=run.id, kind="output", title="خروجی آماده شد.",
payload={"chars": len(reply)},
)
artifact = await self.artifacts.create(
Artifact(
organization_id=organization_id,
project_id=task.project_id,
run_id=run.id,
name=f"خروجی {task.title}"[:200],
kind="text",
content_ref=reply[:8000],
size_bytes=len(reply.encode("utf-8")),
)
)
await self.run_steps.append(
run_id=run.id, kind="artifact", title="سند خروجی ساخته شد.",
payload={"artifact_id": artifact.id},
)
run = await self._finish(run, RunStatus.COMPLETED)
await self.tasks.set_status(task.id, TaskStatus.DONE)
except ProviderUnavailableError as exc:
run = await self._finish(run, RunStatus.FAILED, error=str(exc))
await self.tasks.set_status(task.id, TaskStatus.FAILED)
await self.run_steps.append(
run_id=run.id, kind="error", title="اجرا ناموفق بود.",
payload={"error": str(exc)},
)
except Exception as exc: # noqa: BLE001 - the run must always terminate
run = await self._finish(run, RunStatus.FAILED, error=str(exc))
await self.tasks.set_status(task.id, TaskStatus.FAILED)
await self.run_steps.append(
run_id=run.id, kind="error", title="خطای غیرمنتظره در اجرا.",
payload={"error": str(exc)},
)
return run
async def get_run_or_404(
self, *, organization_id: str, run_id: str
) -> tuple[Run, list[RunStep], list[ToolCall], list[Artifact]]:
run = await self.runs.get(run_id)
if run is None:
raise NotFoundError("اجرا یافت نشد.")
task = await self.tasks.get(run.task_id)
# Re-scope through the task, which is the object that carries the tenant.
if task is None or task.organization_id != organization_id:
raise NotFoundError("اجرا یافت نشد.")
return (
run,
await self.run_steps.list_for_run(run.id),
await self.tool_calls.list_for_run(run.id),
await self.artifacts.list_for_run(run.id),
)
async def cancel_run(self, *, organization_id: str, run_id: str) -> Run:
run, _, _, _ = await self.get_run_or_404(
organization_id=organization_id, run_id=run_id
)
if run.status in TERMINAL_RUN_STATUSES:
raise ConflictError("این اجرا در وضعیت پایانی است و لغو نمی‌شود.")
if not can_transition_run(run.status, RunStatus.CANCELLED):
raise ConflictError("از وضعیت فعلی امکان لغو وجود ندارد.")
run = await self._finish(run, RunStatus.CANCELLED)
await self.run_steps.append(
run_id=run.id, kind="cancelled", title="اجرا توسط کاربر لغو شد."
)
task = await self.tasks.get(run.task_id)
if task is not None and task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
await self.tasks.set_status(task.id, TaskStatus.CANCELLED)
return run
async def list_pending_approvals(self, organization_id: str) -> list[ToolCall]:
return await self.tool_calls.list_pending_approvals_for_org(organization_id)
async def list_artifacts(self, organization_id: str) -> list[Artifact]:
return await self.artifacts.list_for_org(organization_id)
# ------------------------------------------------------------------ #
# Tools & approvals #
# ------------------------------------------------------------------ #
@staticmethod
def tool_catalog() -> tuple[ToolSpec, ...]:
return TOOL_CATALOG
async def set_tool_permission(
self,
*,
organization_id: str,
employee_id: str,
tool_key: str,
allowed: bool,
) -> EmployeeToolPermission:
"""Grant or revoke one tool for one employee.
An unknown tool key is rejected rather than stored: a permission row for
a tool that does not exist is a silent no-op that hides a typo.
"""
await self.get_employee_or_404(
organization_id=organization_id, employee_id=employee_id
)
if not is_known_tool(tool_key):
raise ValidationError(f"ابزار ناشناخته: {tool_key}")
return await self.permissions.set_permission(
employee_id=employee_id, tool_key=tool_key, allowed=allowed
)
async def request_tool_call(
self,
*,
organization_id: str,
run_id: str,
employee_id: str | None,
tool_key: str,
arguments: dict | None = None,
) -> ToolCall:
"""Record an intended tool call, gating it on permission and approval.
The call is persisted in every case so the audit trail shows what was
attempted, not only what succeeded.
"""
run, _, _, _ = await self.get_run_or_404(
organization_id=organization_id, run_id=run_id
)
if not is_known_tool(tool_key):
raise ValidationError(f"ابزار ناشناخته: {tool_key}")
# Deny by default. An employee with no permission row cannot call a tool.
if employee_id is None or not await self.permissions.is_allowed(
employee_id, tool_key
):
raise PermissionDeniedError(
f"کارمند مجاز به استفاده از «{tool_key}» نیست."
)
needs_approval = tool_requires_approval(tool_key)
call = await self.tool_calls.create(
ToolCall(
run_id=run.id,
tool_key=tool_key,
arguments_json=dict(arguments or {}),
status=(
ToolCallStatus.AWAITING_APPROVAL
if needs_approval
else ToolCallStatus.PENDING
),
requires_approval=needs_approval,
)
)
await self.run_steps.append(
run_id=run.id,
kind="tool_call",
title=(
f"درخواست تأیید برای «{tool_key}»"
if needs_approval
else f"فراخوانی «{tool_key}»"
),
payload={"tool_call_id": call.id, "requires_approval": needs_approval},
)
return call
async def decide_approval(
self,
*,
organization_id: str,
tool_call_id: str,
approve: bool,
decided_by_user_id: str,
note: str = "",
) -> ToolCall:
"""Approve or reject a pending tool call. Only a human may call this."""
call = await self.tool_calls.get_for_org(organization_id, tool_call_id)
if call is None:
raise NotFoundError("درخواست تأیید یافت نشد.")
if call.status != ToolCallStatus.AWAITING_APPROVAL:
raise ConflictError("این درخواست در وضعیت انتظار تأیید نیست.")
result = dict(call.result_json)
if note:
result["note"] = note
updated = await self.tool_calls.set_status(
call.id,
ToolCallStatus.APPROVED if approve else ToolCallStatus.REJECTED,
result=result,
approved_by_user_id=decided_by_user_id,
)
await self.run_steps.append(
run_id=call.run_id,
kind="approval",
title=("درخواست تأیید شد." if approve else "درخواست رد شد."),
payload={"tool_call_id": call.id, "tool_key": call.tool_key},
)
return updated or call
# ------------------------------------------------------------------ #
# Internals #
# ------------------------------------------------------------------ #
async def _default_manager(self, organization_id: str) -> Employee | None:
"""The employee that answers when the caller did not pick one."""
for employee in await self.employees.list_for_org(organization_id):
if employee.role_key == RoleKey.MANAGER and employee.is_available:
return employee
return None
async def _system_prompt_for(self, employee: Employee | None) -> str:
if employee is None:
return _SYSTEM_PROMPT
latest = await self.prompts.latest(employee.id)
if latest is None:
return _SYSTEM_PROMPT
return f"{_SYSTEM_PROMPT}\n\n{latest.content}"
async def _resolve_model(self, employee: Employee | None):
"""Map the employee's routing policy to a concrete model. Never exposed."""
mode = RoutingMode.BALANCED
policy_id = employee.routing_policy_id if employee else None
policy = (
await self.routing_policies.get(policy_id)
if policy_id
else await self.routing_policies.get_default()
)
if policy is not None:
mode = policy.mode
model = await self.models.pick_for_mode(mode)
if model is None:
raise ProviderUnavailableError(
"هیچ مدل فعالی برای مسیریابی پیکربندی نشده است."
)
return model
async def _complete(
self,
*,
organization_id: str,
employee: Employee | None,
project_id: str | None,
user_id: str | None,
system: str,
messages: list[ChatMessage],
run_id: str | None = None,
) -> str:
"""Run one completion and record its usage. The single model entry point."""
model = await self._resolve_model(employee)
credential = await self.credentials.get_active_for_provider(model.provider_id)
response = await self.provider.complete(
model=model, credential=credential, messages=messages, system=system
)
await self._record_usage(
organization_id=organization_id,
project_id=project_id,
user_id=user_id,
employee_id=employee.id if employee else None,
run_id=run_id,
unit=RateUnit.MODEL_INPUT_TOKENS,
quantity=float(response.input_tokens),
)
await self._record_usage(
organization_id=organization_id,
project_id=project_id,
user_id=user_id,
employee_id=employee.id if employee else None,
run_id=run_id,
unit=RateUnit.MODEL_OUTPUT_TOKENS,
quantity=float(response.output_tokens),
)
return response.content
async def _record_usage(
self,
*,
organization_id: str,
unit: RateUnit,
quantity: float,
project_id: str | None = None,
user_id: str | None = None,
employee_id: str | None = None,
run_id: str | None = None,
) -> None:
card = await self.rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0
await self.ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=project_id,
user_id=user_id,
employee_id=employee_id,
run_id=run_id,
unit=unit,
quantity=quantity,
unit_price=unit_price,
internal_cost_estimate=quantity * internal_cost,
total_price=quantity * unit_price,
metadata_json={"priced": card is not None},
)
)
async def _advance(self, run: Run, target: RunStatus) -> Run:
if not can_transition_run(run.status, target):
raise ConflictError(f"گذار نامعتبر از {run.status} به {target}.")
return await self.runs.set_status(run.id, target) or run
async def _finish(self, run: Run, target: RunStatus, *, error: str | None = None) -> Run:
from app.domain.base import utcnow
if not can_transition_run(run.status, target):
# Already terminal — return the current row untouched.
return await self.runs.get(run.id) or run
updated = await self.runs.set_status(
run.id, target, error=error, finished_at=utcnow()
)
return updated or run
@staticmethod
def _task_brief(task: Task) -> str:
parts = [f"عنوان کار: {task.title}"]
if task.description:
parts.append(f"توضیحات: {task.description}")
return "\n".join(parts)
+1
View File
@@ -0,0 +1 @@
"""Cross-cutting concerns: configuration, database, security, crypto, errors."""
+152
View File
@@ -0,0 +1,152 @@
"""Application configuration.
All external providers default to ``mock`` so the whole stack runs with zero
credentials. Real adapters stay behind the ``*_PROVIDER`` switches.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# web/apps/api/app/core/config.py -> web/
WEB_ROOT = Path(__file__).resolve().parents[4]
#: Values that are obviously placeholders. Never acceptable in production.
_INSECURE_SECRETS = frozenset(
{"", "change-me", "changeme", "secret", "test", "dev", "password"}
)
#: HS256 signs with a 256-bit key; anything shorter weakens the signature.
MIN_SECRET_LENGTH = 32
#: The development CORS allowlist. ``localhost`` and ``127.0.0.1`` are distinct
#: browser origins, so both spellings are listed; leaving one out makes the dev
#: UI fail with an opaque "Disallowed CORS origin".
DEVELOPMENT_CORS_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(WEB_ROOT / ".env", WEB_ROOT / ".env.local"),
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- app -----------------------------------------------------------------
app_env: str = "development"
app_name: str = "AGY Web"
app_url: str = "http://localhost:3000"
api_url: str = "http://localhost:8000"
#: Both loopback spellings, on purpose. ``localhost`` and ``127.0.0.1`` are
#: *different origins* to a browser, so listing only one produces a bare
#: "Disallowed CORS origin" with nothing to hint at the cause — the classic
#: failure being a dev server started with ``-H 127.0.0.1``. Override with
#: ``CORS_ORIGINS``; production must set it explicitly (a wildcard is
#: refused outright by the validator below).
cors_origins: str = DEVELOPMENT_CORS_ORIGINS
# --- data stores ---------------------------------------------------------
database_url: str = "sqlite+aiosqlite:///./agy_web.db"
redis_url: str = "redis://localhost:6379/0"
# --- security ------------------------------------------------------------
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 60 * 24
encryption_key: str = ""
# --- AI models (hidden from customers) -----------------------------------
ai_default_provider: str = "mock"
openai_compatible_base_url: str = ""
openai_compatible_api_key: str = ""
# --- git -----------------------------------------------------------------
git_provider: str = "mock"
gitea_url: str = ""
gitea_internal_url: str = ""
gitea_admin_user: str = "root"
gitea_admin_token: str = ""
# --- preview domain routing ----------------------------------------------
domain_router: str = "mock"
preview_root_domain: str = "msa.artacloud.ir"
preview_port_start: int = 8080
preview_port_end: int = 8999
caddyfile_path: str = "/etc/caddy/Caddyfile"
max_subdomains_per_project: int = 5
# --- sandbox -------------------------------------------------------------
sandbox_provider: str = "mock"
daytona_api_key: str = ""
daytona_api_url: str = ""
e2b_api_key: str = ""
sandbox_command_timeout_seconds: int = 120
sandbox_max_output_bytes: int = 200_000
# --- payments ------------------------------------------------------------
payment_provider: str = "mock"
stripe_secret_key: str = ""
stripe_webhook_secret: str = ""
# --- error logging -------------------------------------------------------
#: Master switch. When off, failures are still logged to stderr but nothing
#: is written to ``error_events``.
error_log_enabled: bool = True
#: Whether 4xx responses are persisted too. Off by default: a public API
#: takes a constant stream of 404s from scanners, and recording them all
#: buries the genuine bugs. Turn it on to debug a misbehaving client.
#: 5xx responses are recorded regardless of this setting.
error_log_client_errors: bool = False
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def is_production(self) -> bool:
return self.app_env.lower() in {"production", "prod"}
@model_validator(mode="after")
def _refuse_insecure_production_secrets(self) -> Settings:
"""Fail closed instead of booting a production API with a weak secret.
A short or placeholder ``JWT_SECRET`` lets anyone mint a token for any
tenant — the single worst failure mode this service has. Development
keeps working with the documented default; production must be explicit.
"""
if not self.is_production:
return self
problems: list[str] = []
if self.jwt_secret.strip().lower() in _INSECURE_SECRETS:
problems.append("JWT_SECRET is still the placeholder value")
elif len(self.jwt_secret) < MIN_SECRET_LENGTH:
problems.append(
f"JWT_SECRET is {len(self.jwt_secret)} chars; "
f"at least {MIN_SECRET_LENGTH} are required"
)
if not self.encryption_key.strip():
problems.append("ENCRYPTION_KEY is empty; stored credentials cannot be encrypted")
if self.cors_origins.strip() == "*":
problems.append("CORS_ORIGINS must not be '*' in production")
elif self.cors_origins.strip() == DEVELOPMENT_CORS_ORIGINS:
problems.append(
"CORS_ORIGINS is still the development default; "
"set it to the real frontend origin"
)
if problems:
raise ValueError(
"Unsafe production configuration: " + "; ".join(problems) + "."
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+54
View File
@@ -0,0 +1,54 @@
"""Symmetric encryption for secrets at rest (provider API keys, git credentials).
Fernet (AES-128-CBC + HMAC) with a key derived from ``ENCRYPTION_KEY``. The
derivation is deterministic so a rotated key invalidates stored ciphertexts
loudly instead of silently returning garbage.
"""
from __future__ import annotations
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
from app.core.errors import AppError
def _derive_key(raw: str) -> bytes:
"""Fernet needs exactly 32 url-safe base64 bytes."""
digest = hashlib.sha256(raw.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
_fernet: Fernet | None = None
def _cipher() -> Fernet:
global _fernet
if _fernet is None:
material = settings.encryption_key.strip() or settings.jwt_secret
_fernet = Fernet(_derive_key(material))
return _fernet
def encrypt(plaintext: str) -> str:
return _cipher().encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt(ciphertext: str) -> str:
try:
return _cipher().decrypt(ciphertext.encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError) as exc:
raise AppError(
"رمزنگاری ناموفق بود؛ ممکن است ENCRYPTION_KEY تغییر کرده باشد."
) from exc
def mask(secret: str | None, keep: int = 4) -> str:
"""Safe display form: ``••••1234``. Never returns the full secret."""
if not secret:
return ""
tail = secret[-keep:] if len(secret) > keep else ""
return "•" * 6 + tail
+51
View File
@@ -0,0 +1,51 @@
"""Async SQLAlchemy engine, session factory and declarative base.
The ORM lives in ``infrastructure``; ``domain`` never imports this module.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
"""Declarative base for every ORM table."""
def _engine_kwargs() -> dict:
url = settings.database_url
if url.startswith("sqlite"):
return {"echo": False, "future": True}
return {
"echo": False,
"future": True,
"pool_pre_ping": True,
"pool_size": 10,
"max_overflow": 20,
}
engine = create_async_engine(settings.database_url, **_engine_kwargs())
SessionFactory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding one transactional session per request."""
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+63
View File
@@ -0,0 +1,63 @@
"""Domain-level errors and their HTTP mapping.
Domain and application layers raise these; only ``interfaces/http`` knows about
status codes. That keeps FastAPI out of the inner layers.
"""
from __future__ import annotations
from typing import Any
class AppError(Exception):
"""Base class for every expected, user-facing failure."""
status_code = 400
code = "app_error"
def __init__(self, message: str, *, details: Any = None) -> None:
super().__init__(message)
self.message = message
self.details = details
class ValidationError(AppError):
status_code = 422
code = "validation_error"
class AuthenticationError(AppError):
status_code = 401
code = "authentication_error"
class PermissionDeniedError(AppError):
status_code = 403
code = "permission_denied"
class NotFoundError(AppError):
status_code = 404
code = "not_found"
class ConflictError(AppError):
status_code = 409
code = "conflict"
class QuotaExceededError(AppError):
status_code = 402
code = "quota_exceeded"
class ProviderUnavailableError(AppError):
status_code = 503
code = "provider_unavailable"
class ApprovalRequiredError(AppError):
"""A dangerous operation was attempted without an approval record."""
status_code = 409
code = "approval_required"
+293
View File
@@ -0,0 +1,293 @@
"""Scrub secrets out of anything before it is persisted.
This module exists because of one hard rule: **an error log that stores a
password is worse than no error log at all.** Error records are written from
inside exception handlers, where the values at hand are exactly the values most
likely to carry credentials — request bodies, ``Authorization`` headers, DSNs,
provider API keys, decrypted git tokens. Anything that reaches the database must
pass through here first.
Two independent layers, because either one alone leaks:
1. **Structural** — a key that *means* a secret (``password``, ``api_key``,
``refresh_token``, …) has its whole value replaced, whatever the value looks
like. This catches secrets in shapes no pattern would recognise.
2. **Lexical** — value patterns that are recognisable regardless of their key:
PEM blocks, JWTs, ``scheme://user:pass@host`` URLs, ``Bearer`` headers,
``sk-…``/``ghp_…``/``AKIA…`` provider keys, and ``password=…`` fragments
embedded in a larger string.
On top of both sits a third net: the **configured secret values themselves**
(``JWT_SECRET``, ``ENCRYPTION_KEY``, every ``*_API_KEY``/``*_TOKEN``) are
replaced literally wherever they appear. A secret that is only ever compared by
pattern will eventually be logged verbatim; a secret that is matched by value
cannot be.
Deliberately dependency-free and framework-free: the recorder, the HTTP layer
and the workers all import it, and it must never fail — a redactor that raises
inside an exception handler turns a 500 into a hang.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
#: What replaces a scrubbed value. Deliberately obvious in a log so a reader can
#: tell "we removed this" from "this was empty".
REDACTED = "[REDACTED]"
#: Longest string we will hand to the regex engine. Error payloads can contain
#: multi-megabyte blobs; redaction cost must stay bounded and predictable.
MAX_INPUT_CHARS = 64_000
# ---------------------------------------------------------------------------
# Layer 1 — structural: keys that mean "secret"
# ---------------------------------------------------------------------------
#: Exact key names (after normalisation) whose value is never safe to store.
_SENSITIVE_KEYS = frozenset(
{
"password",
"passwd",
"pwd",
"passphrase",
"secret",
"client_secret",
"token",
"api_key",
"apikey",
"authorization",
"auth",
"credentials",
"credential",
"private_key",
"access_token",
"refresh_token",
"id_token",
"session",
"session_id",
"session_token",
"cookie",
"set_cookie",
"jwt",
"bearer",
"encryption_key",
"jwt_secret",
"signing_key",
"webhook_secret",
"signature",
}
)
#: Suffixes that make a key sensitive however it is prefixed: ``db_password``,
#: ``gitea_admin_token``, ``stripe_secret_key``, ``openai_api_key``.
#:
#: Suffix — not substring — matching on purpose. ``token_count`` and
#: ``input_tokens`` are *usage* fields, and redacting them would silently
#: destroy the billing data these same records are meant to help debug.
_SENSITIVE_SUFFIXES = (
"_password",
"_passwd",
"_secret",
"_secret_key",
"_token",
"_api_key",
"_apikey",
"_private_key",
"_credential",
"_credentials",
)
#: Keys that look sensitive by suffix rule but are known-safe counters.
_SAFE_KEYS = frozenset(
{
"token_count",
"token_limit",
"tokens",
"input_tokens",
"output_tokens",
"total_tokens",
"prompt_tokens",
"completion_tokens",
"max_tokens",
"cached_tokens",
"secret_count",
}
)
_NORMALISE = re.compile(r"[^a-z0-9]+")
#: Splits camelCase so ``refreshToken`` and ``apiKey`` normalise the same way as
#: their snake_case spellings, instead of collapsing to one unguessable word.
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
def _normalise_key(key: str) -> str:
"""``X-API-Key`` / ``apiKey`` / ``api-key`` all become ``api_key``."""
spaced = _CAMEL_BOUNDARY.sub("_", key.strip())
return _NORMALISE.sub("_", spaced.lower()).strip("_")
def is_sensitive_key(key: str) -> bool:
"""True when a value stored under ``key`` must be replaced wholesale."""
normalised = _normalise_key(key)
if not normalised or normalised in _SAFE_KEYS:
return False
if normalised in _SENSITIVE_KEYS:
return True
return normalised.endswith(_SENSITIVE_SUFFIXES)
# ---------------------------------------------------------------------------
# Layer 2 — lexical: values that are secrets whatever their key
# ---------------------------------------------------------------------------
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
# PEM / OpenSSH private keys, possibly multi-line.
(
re.compile(
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
re.DOTALL,
),
"[REDACTED_PRIVATE_KEY]",
),
# scheme://user:password@host — DSNs and git remotes. Keeps the scheme and
# the host so the record stays useful; drops the userinfo.
(
re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/\s:@]+:[^/\s@]+@"),
r"\g<scheme>[REDACTED]@",
),
# "Authorization: Bearer eyJ…" and friends. Keeps the scheme.
(
re.compile(r"(?i)\b(bearer|basic|token|apikey)\s+[A-Za-z0-9\-._~+/=]{8,}"),
r"\1 [REDACTED]",
),
# JSON Web Tokens: three base64url segments, always starting "eyJ".
(
re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b"),
"[REDACTED_JWT]",
),
# Provider key shapes worth naming individually.
(re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b"), "[REDACTED_KEY]"),
(re.compile(r"\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bya29\.[0-9A-Za-z_\-]+\b"), "[REDACTED_KEY]"),
# "password=hunter2", '"api_key": "…"', "secret: abc" inside a longer text.
# The value is a quoted run, a scheme-prefixed token ("Bearer eyJ…"), or a
# bare run up to the next delimiter.
(
re.compile(
r"(?i)\b(password|passwd|pwd|secret|token|api[_\-]?key|client[_\-]?secret"
r"|access[_\-]?token|refresh[_\-]?token|authorization|private[_\-]?key)"
r"\b(\s*[:=]\s*)"
r"(\"[^\"]{0,512}\""
r"|'[^']{0,512}'"
r"|(?:bearer|basic|token|apikey)\s+\S{1,512}"
r"|[^\s,;&)\]}\"']{1,512})"
),
r"\1\2[REDACTED]",
),
)
def _configured_secret_values() -> tuple[str, ...]:
"""Literal secrets from settings, longest first.
Read lazily on every call rather than cached at import: the test suite
rewrites ``settings.jwt_secret`` in a fixture, and a stale cache would keep
the old value while the new one leaked.
"""
from app.core.config import settings
candidates = (
settings.jwt_secret,
settings.encryption_key,
settings.openai_compatible_api_key,
settings.gitea_admin_token,
settings.daytona_api_key,
settings.e2b_api_key,
settings.stripe_secret_key,
settings.stripe_webhook_secret,
)
# Short strings and documented placeholders are not secrets. Replacing
# "change-me" would garble messages for no security gain.
placeholders = {"change-me", "changeme", "secret", "test", "dev", "password", "none"}
usable = {
value
for value in candidates
if isinstance(value, str) and len(value) >= 8 and value.lower() not in placeholders
}
return tuple(sorted(usable, key=len, reverse=True))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def redact_text(value: str) -> str:
"""Scrub secrets from a free-form string. Never raises."""
try:
text = value if len(value) <= MAX_INPUT_CHARS else value[:MAX_INPUT_CHARS] + "…[truncated]"
for secret in _configured_secret_values():
if secret in text:
text = text.replace(secret, REDACTED)
for pattern, replacement in _PATTERNS:
text = pattern.sub(replacement, text)
return text
except Exception: # pragma: no cover - redaction must never break a handler
# If a pattern ever explodes, drop the text rather than the request.
return REDACTED
def redact(value: Any) -> Any:
"""Recursively scrub an arbitrary JSON-ish value. Never raises."""
try:
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return redact_text(value)
if isinstance(value, (bytes, bytearray, memoryview)):
return f"<{len(bytes(value))} bytes>"
if isinstance(value, Mapping):
return redact_mapping(value)
if isinstance(value, (list, tuple, set, frozenset)):
return [redact(item) for item in value]
return redact_text(str(value))
except Exception: # pragma: no cover - see redact_text
return REDACTED
def redact_mapping(data: Mapping[Any, Any] | None) -> dict[str, Any]:
"""Scrub a mapping, replacing any sensitive key's value outright."""
if not data:
return {}
try:
result: dict[str, Any] = {}
for key, item in data.items():
name = str(key)
result[name] = REDACTED if is_sensitive_key(name) else redact(item)
return result
except Exception: # pragma: no cover - see redact_text
return {"_redaction_failed": True}
def redact_headers(headers: Mapping[Any, Any] | None) -> dict[str, str]:
"""Scrub HTTP headers, keeping the shape of the request for debugging.
Header names are normalised to lower-case; ``authorization``, ``cookie`` and
``x-api-key`` fall out as sensitive through the same key rules as JSON.
"""
if not headers:
return {}
try:
result: dict[str, str] = {}
for key, value in headers.items():
name = str(key).lower()
result[name] = REDACTED if is_sensitive_key(name) else str(redact(value))
return result
except Exception: # pragma: no cover - see redact_text
return {}
+72
View File
@@ -0,0 +1,72 @@
"""Password hashing and JWT issuance/verification.
Argon2id for passwords, HS256 JWTs for sessions. No secret ever leaves this module.
"""
from __future__ import annotations
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from app.core.config import settings
from app.core.errors import AuthenticationError
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError, ValueError):
return False
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except (InvalidHashError, ValueError):
return True
def create_access_token(user_id: str, *, extra: dict[str, Any] | None = None) -> str:
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": str(user_id),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.access_token_ttl_minutes)).timestamp()),
"jti": uuid.uuid4().hex,
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise AuthenticationError("نشست منقضی شده است.") from exc
except jwt.PyJWTError as exc:
raise AuthenticationError("توکن نامعتبر است.") from exc
def generate_token(nbytes: int = 32) -> str:
"""Opaque token for invites / one-time links."""
return secrets.token_urlsafe(nbytes)
def hash_token(token: str) -> str:
"""Invite tokens are stored hashed, never in plaintext."""
import hashlib
return hashlib.sha256(token.encode("utf-8")).hexdigest()
View File
+109
View File
@@ -0,0 +1,109 @@
"""AI Control Plane domain: providers, credentials, models, routing, rate cards.
Everything here is platform-owner-only. A normal customer never receives these
records through the API — the application layer strips them.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
class ProviderType(StrEnum):
MOCK = "mock"
OPENAI_COMPATIBLE = "openai_compatible"
ANTHROPIC = "anthropic"
GEMINI = "gemini"
OPENROUTER = "openrouter"
class RoutingMode(StrEnum):
FAST = "fast"
CHEAP = "cheap"
BALANCED = "balanced"
REASONING = "reasoning"
VISION = "vision"
CODING = "coding"
class Capability(StrEnum):
TEXT = "text"
VISION = "vision"
TOOLS = "tools"
REASONING = "reasoning"
LONG_CONTEXT = "long_context"
CODE = "code"
class RateUnit(StrEnum):
MODEL_INPUT_TOKENS = "model_input_tokens"
MODEL_OUTPUT_TOKENS = "model_output_tokens"
SANDBOX_CPU_SECOND = "sandbox_cpu_second"
SANDBOX_MEMORY_GB_SECOND = "sandbox_memory_gb_second"
STORAGE_GB_DAY = "storage_gb_day"
TOOL_CALL = "tool_call"
PREMIUM_EMPLOYEE_RUN = "premium_employee_run"
@dataclass(slots=True, kw_only=True)
class Provider:
name: str
type: ProviderType = ProviderType.MOCK
base_url: str = ""
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class ModelCredential:
provider_id: str
name: str
encrypted_api_key: str
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class AiModel:
provider_id: str
internal_name: str
display_name_for_admin: str
capabilities: list[str] = field(default_factory=list)
input_price_per_1m: float = 0.0
output_price_per_1m: float = 0.0
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
def supports(self, capability: str | Capability) -> bool:
return str(capability) in set(self.capabilities)
@dataclass(slots=True, kw_only=True)
class RoutingPolicy:
name: str
mode: RoutingMode = RoutingMode.BALANCED
description: str = ""
fallback_policy_json: dict = field(default_factory=dict)
budget_limits_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
is_default: bool = False
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class RateCard:
name: str
unit: RateUnit
customer_price: float
internal_cost_estimate: float = 0.0
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
+153
View File
@@ -0,0 +1,153 @@
"""Observability & Audit domain: the immutable trail of who did what."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from app.domain.base import new_id, utcnow
class AuditAction:
"""Well-known auditable actions. Free-form strings are allowed but these are
the ones the application layer emits so far."""
LOGIN = "auth.login"
LOGOUT = "auth.logout"
SIGNUP = "auth.signup"
ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete"
MEMBER_INVITE = "member.invite"
MEMBER_ROLE_CHANGE = "member.role_change"
MEMBER_REMOVE = "member.remove"
EMPLOYEE_CREATE = "employee.create"
EMPLOYEE_UPDATE = "employee.update"
REPO_CREATE = "repo.create"
REPO_DELETE = "repo.delete"
DOMAIN_CREATE = "domain.create"
DOMAIN_DELETE = "domain.delete"
SANDBOX_COMMAND = "sandbox.command"
SANDBOX_DESTROY = "sandbox.destroy"
#: A platform worker stopped a sandbox session that had gone idle. System
#: action, not a tenant one — the actor is the platform, not a user.
SANDBOX_REAP = "sandbox.reap"
APPROVAL_DECISION = "approval.decision"
BILLING_CREDIT = "billing.credit"
BILLING_PAYMENT = "billing.payment"
PROVIDER_CHANGE = "provider.change"
MODEL_CHANGE = "model.change"
ROUTING_CHANGE = "routing.change"
PROJECT_CREATE = "project.create"
PROJECT_DELETE = "project.delete"
TASK_CREATE = "task.create"
RUN_CANCEL = "run.cancel"
#: A platform owner triaged an error in the console. Auditable so "who
#: decided this bug was fixed?" has an answer.
ERROR_RESOLVE = "error.resolve"
@dataclass(slots=True, kw_only=True)
class AuditEvent:
action: str
organization_id: str | None = None
actor_user_id: str | None = None
target_type: str = ""
target_id: str = ""
metadata_json: dict = field(default_factory=dict)
ip_address: str = ""
user_agent: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Trace:
organization_id: str
name: str = ""
run_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Span:
trace_id: str
name: str
kind: str = "internal"
parent_span_id: str | None = None
attributes_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
started_at: datetime = field(default_factory=utcnow)
ended_at: datetime | None = None
@property
def duration_ms(self) -> int | None:
if self.ended_at is None:
return None
return int((self.ended_at - self.started_at).total_seconds() * 1000)
@dataclass(slots=True, kw_only=True)
class ErrorEvent:
"""A persisted failure, recorded so it can be diagnosed later.
Deliberately *not* a domain aggregate: nothing in the business rules depends
on it. It is an operational record, which is why it carries transport-level
detail (method, path, status code) that no domain model would ever hold.
"""
kind: str
message: str
organization_id: str | None = None
run_id: str | None = None
stack: str = ""
metadata_json: dict = field(default_factory=dict)
#: Where the failure happened: ``http``, ``worker`` or ``startup``.
source: str = "http"
#: ``error`` for anything unexpected, ``warning`` for expected 4xx noise,
#: ``critical`` for startup/infrastructure failures.
severity: str = "error"
#: Exception class name (``ValueError``, ``IntegrityError``, …). Empty when
#: the failure was not an exception.
error_type: str = ""
#: Stable hash of the failure's identity, so a thousand occurrences of the
#: same bug group into one row in the console instead of a thousand.
fingerprint: str = ""
status_code: int | None = None
method: str = ""
path: str = ""
user_id: str | None = None
#: Correlates this record with the response the caller received and with
#: every log line written while handling the same request.
request_id: str = ""
ip: str = ""
user_agent: str = ""
resolved_at: datetime | None = None
resolved_note: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
class ErrorSource:
"""Where an error was caught. Kept as strings so a new source needs no
migration."""
HTTP = "http"
WORKER = "worker"
STARTUP = "startup"
class ErrorSeverity:
ERROR = "error"
WARNING = "warning"
CRITICAL = "critical"
@dataclass(slots=True, kw_only=True)
class QualityMetric:
organization_id: str
name: str
value: float
dimensions_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
+49
View File
@@ -0,0 +1,49 @@
"""Shared domain primitives.
Pure Python — no framework, no I/O. The domain layer must stay importable
without FastAPI, SQLAlchemy or a database connection.
"""
from __future__ import annotations
import re
import uuid
from datetime import UTC, datetime
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def new_id() -> str:
"""Opaque, URL-safe entity id (portable across SQLite and PostgreSQL)."""
return uuid.uuid4().hex
def utcnow() -> datetime:
return datetime.now(UTC)
def slugify(value: str, *, max_length: int = 48) -> str:
"""ASCII slug. Non-ASCII input (e.g. Persian) collapses to a stable fallback.
Persian organization names do not transliterate, so we keep a short random
suffix appended by the caller when the slug would be empty.
"""
slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-")
slug = slug[:max_length].strip("-")
if not slug:
slug = f"x{uuid.uuid4().hex[:8]}"
return slug
def ensure_unique_slug(base: str, taken: set[str], *, max_length: int = 48) -> str:
"""Append ``-2``, ``-3`` ... until the slug is free."""
candidate = base[:max_length]
if candidate not in taken:
return candidate
n = 2
while True:
suffix = f"-{n}"
candidate = f"{base[: max_length - len(suffix)]}{suffix}"
if candidate not in taken:
return candidate
n += 1
+127
View File
@@ -0,0 +1,127 @@
"""Billing & Usage domain.
The internal usage ledger is the source of truth. Stripe (or any other payment
provider) is an *adapter* that mirrors it — never the other way around.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.ai_control.models import RateUnit
from app.domain.base import new_id, utcnow
class SubscriptionStatus(StrEnum):
TRIALING = "trialing"
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELED = "canceled"
class InvoiceStatus(StrEnum):
DRAFT = "draft"
OPEN = "open"
PAID = "paid"
VOID = "void"
UNCOLLECTIBLE = "uncollectible"
class PaymentStatus(StrEnum):
PENDING = "pending"
SUCCEEDED = "succeeded"
FAILED = "failed"
REFUNDED = "refunded"
@dataclass(slots=True, kw_only=True)
class Plan:
name: str
monthly_price: float = 0.0
included_credit: float = 0.0
features_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Subscription:
organization_id: str
plan_id: str
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
external_subscription_id: str | None = None
current_period_end: datetime | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class CreditWallet:
organization_id: str
balance: float = 0.0
currency: str = "USD"
low_balance_threshold: float = 5.0
id: str = field(default_factory=new_id)
updated_at: datetime = field(default_factory=utcnow)
@property
def is_low(self) -> bool:
return self.balance <= self.low_balance_threshold
def can_afford(self, amount: float) -> bool:
return self.balance >= amount
def debit(self, amount: float) -> None:
self.balance -= amount
self.updated_at = utcnow()
def credit(self, amount: float) -> None:
self.balance += amount
self.updated_at = utcnow()
@dataclass(slots=True, kw_only=True)
class UsageEvent:
"""One billable unit of work. Append-only."""
organization_id: str
unit: RateUnit
quantity: float
unit_price: float = 0.0
internal_cost_estimate: float = 0.0
total_price: float = 0.0
project_id: str | None = None
user_id: str | None = None
employee_id: str | None = None
run_id: str | None = None
metadata_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Invoice:
organization_id: str
amount: float
status: InvoiceStatus = InvoiceStatus.DRAFT
currency: str = "USD"
external_invoice_id: str | None = None
period_start: datetime | None = None
period_end: datetime | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Payment:
organization_id: str
amount: float
status: PaymentStatus = PaymentStatus.PENDING
provider: str = "mock"
external_payment_id: str | None = None
invoice_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
+149
View File
@@ -0,0 +1,149 @@
"""Identity & Access domain: users, organizations, memberships, invites."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
class Role(StrEnum):
OWNER = "owner"
ADMIN = "admin"
MANAGER = "manager"
MEMBER = "member"
VIEWER = "viewer"
#: Higher rank implies every permission of the lower ranks.
ROLE_RANK: dict[Role, int] = {
Role.VIEWER: 10,
Role.MEMBER: 20,
Role.MANAGER: 30,
Role.ADMIN: 40,
Role.OWNER: 50,
}
#: Capabilities used across the application layer.
CAN_MANAGE_ORG = "org.manage"
CAN_MANAGE_MEMBERS = "org.members"
CAN_MANAGE_PROJECTS = "projects.manage"
CAN_MANAGE_EMPLOYEES = "employees.manage"
CAN_RUN_TASKS = "tasks.run"
CAN_VIEW_BILLING = "billing.view"
CAN_MANAGE_BILLING = "billing.manage"
CAN_VIEW_AUDIT = "audit.view"
CAN_USE_SANDBOX = "sandbox.use"
_ROLE_CAPABILITIES: dict[Role, set[str]] = {
Role.VIEWER: set(),
Role.MEMBER: {CAN_RUN_TASKS, CAN_VIEW_BILLING},
Role.MANAGER: {
CAN_RUN_TASKS,
CAN_VIEW_BILLING,
CAN_MANAGE_PROJECTS,
CAN_MANAGE_EMPLOYEES,
CAN_USE_SANDBOX,
},
Role.ADMIN: {
CAN_RUN_TASKS,
CAN_VIEW_BILLING,
CAN_MANAGE_BILLING,
CAN_MANAGE_PROJECTS,
CAN_MANAGE_EMPLOYEES,
CAN_USE_SANDBOX,
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
},
Role.OWNER: {
CAN_RUN_TASKS,
CAN_VIEW_BILLING,
CAN_MANAGE_BILLING,
CAN_MANAGE_PROJECTS,
CAN_MANAGE_EMPLOYEES,
CAN_USE_SANDBOX,
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
CAN_MANAGE_ORG,
},
}
def role_has_capability(role: Role, capability: str) -> bool:
return capability in _ROLE_CAPABILITIES.get(role, set())
class MembershipStatus(StrEnum):
ACTIVE = "active"
INVITED = "invited"
SUSPENDED = "suspended"
@dataclass(slots=True, kw_only=True)
class User:
email: str
password_hash: str
name: str = ""
id: str = field(default_factory=new_id)
avatar_url: str | None = None
is_platform_owner: bool = False
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
@property
def display_name(self) -> str:
return self.name.strip() or self.email.split("@")[0]
def normalized_email(self) -> str:
return self.email.strip().lower()
@dataclass(slots=True, kw_only=True)
class Organization:
name: str
slug: str
owner_user_id: str
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Membership:
organization_id: str
user_id: str
role: Role = Role.MEMBER
status: MembershipStatus = MembershipStatus.ACTIVE
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def is_active(self) -> bool:
return self.status == MembershipStatus.ACTIVE
def has(self, capability: str) -> bool:
return self.is_active and role_has_capability(self.role, capability)
def outranks(self, other_role: Role) -> bool:
return ROLE_RANK[self.role] > ROLE_RANK[other_role]
@dataclass(slots=True, kw_only=True)
class Invite:
organization_id: str
email: str
role: Role
token_hash: str
expires_at: datetime
id: str = field(default_factory=new_id)
invited_by_user_id: str | None = None
accepted_at: datetime | None = None
created_at: datetime = field(default_factory=utcnow)
def is_expired(self, now: datetime | None = None) -> bool:
return (now or utcnow()) >= self.expires_at
def is_pending(self, now: datetime | None = None) -> bool:
return self.accepted_at is None and not self.is_expired(now)
+74
View File
@@ -0,0 +1,74 @@
"""Knowledge domain: sources, documents, chunks and retrieval policy.
Retrieval is permission-aware: an employee only sees knowledge scoped to its
organization (and optionally its project).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
class SourceKind(StrEnum):
FILE = "file"
TEXT = "text"
URL = "url"
REPO = "repo"
class EmbeddingJobStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
@dataclass(slots=True, kw_only=True)
class KnowledgeSource:
organization_id: str
name: str
kind: SourceKind = SourceKind.TEXT
uri: str = ""
project_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Document:
source_id: str
title: str
content: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class DocumentChunk:
document_id: str
index: int
content: str
embedding_json: list[float] = field(default_factory=list)
id: str = field(default_factory=new_id)
@dataclass(slots=True, kw_only=True)
class EmbeddingJob:
source_id: str
status: EmbeddingJobStatus = EmbeddingJobStatus.PENDING
error: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class RetrievalPolicy:
organization_id: str
name: str
scope_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
+121
View File
@@ -0,0 +1,121 @@
"""Platform-wide validation rules carried over from the existing infrastructure.
These are pure functions with no I/O, so both the application layer *and* the
adapters can enforce them. Defence in depth: a rule that is only checked in one
place is a rule that will eventually be bypassed.
Reference: ``docs/current-infra-notes.md`` §1.
"""
from __future__ import annotations
import re
DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir"
#: Ports that are never allocatable (system services + data stores).
SYSTEM_RESERVED_PORTS: frozenset[int] = frozenset(
{22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008}
)
#: Ports of internal platform services. A tenant preview must never proxy to
#: these, otherwise a public hostname would expose another component.
SYSTEM_PROTECTED_PORTS: frozenset[int] = (
frozenset({3000, 35555, 35556, 35557}) | SYSTEM_RESERVED_PORTS
)
DEFAULT_PORT_RANGE_START = 8080
DEFAULT_PORT_RANGE_END = 8999
LABEL_MIN = 3
LABEL_MAX = 40
_LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
_PROXY_TARGET_RE = re.compile(r"^(https?://)?(localhost|127\.0\.0\.1):(\d{1,5})$")
class RuleViolation(ValueError):
"""Raised when a request violates a platform safety rule."""
def validate_subdomain_label(label: str) -> str:
"""Normalise and validate a single DNS label.
Only ``a-z0-9-``, must start and end alphanumeric, 3-40 characters.
"""
clean = (label or "").strip().lower()
if not (LABEL_MIN <= len(clean) <= LABEL_MAX):
raise RuleViolation(
f"نام زیردامنه باید بین {LABEL_MIN} تا {LABEL_MAX} کاراکتر باشد."
)
if not _LABEL_RE.match(clean):
raise RuleViolation("نام زیردامنه فقط می‌تواند شامل حروف کوچک، عدد و خط تیره باشد.")
return clean
def build_host(label: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
return f"{validate_subdomain_label(label)}.{root_domain.strip().lower()}"
def validate_managed_host(host: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
"""Validate a fully-qualified host against the managed wildcard domain.
Rejects anything that is not a single label under the root domain — this is
the anti-injection check that keeps a crafted host from breaking out of a
Caddy site block or pointing at an arbitrary host.
"""
clean = (host or "").strip().lower()
suffix = f".{root_domain.strip().lower()}"
if not clean.endswith(suffix):
raise RuleViolation(f"دامنه باید زیرمجموعهٔ «{root_domain}» باشد.")
label = clean[: -len(suffix)]
if "." in label:
raise RuleViolation("فقط یک برچسب (single label) مجاز است.")
validate_subdomain_label(label)
return clean
def validate_proxy_target(target: str) -> tuple[str, int]:
"""Ensure the reverse-proxy target is a loopback address with a numeric port.
Blocks SSRF: a tenant must not be able to proxy a public hostname to an
internal service.
"""
clean = (target or "").strip()
match = _PROXY_TARGET_RE.match(clean)
if not match:
raise RuleViolation(
"مقصد reverse proxy باید localhost یا 127.0.0.1 با پورت عددی باشد."
)
port = int(match.group(3))
if not (1 <= port <= 65535):
raise RuleViolation("شمارهٔ پورت نامعتبر است.")
return match.group(2), port
def is_port_allocatable(
port: int,
*,
start: int = DEFAULT_PORT_RANGE_START,
end: int = DEFAULT_PORT_RANGE_END,
) -> bool:
if port in SYSTEM_RESERVED_PORTS:
return False
return start <= port <= end
def assert_port_allocatable(
port: int,
*,
start: int = DEFAULT_PORT_RANGE_START,
end: int = DEFAULT_PORT_RANGE_END,
) -> int:
if not is_port_allocatable(port, start=start, end=end):
raise RuleViolation(f"پورت {port} قابل تخصیص نیست.")
return port
def assert_not_protected_port(port: int) -> int:
"""A preview domain must never point at an internal platform port."""
if port in SYSTEM_PROTECTED_PORTS:
raise RuleViolation(f"اتصال به پورت محافظت‌شدهٔ {port} ممنوع است.")
return port
+56
View File
@@ -0,0 +1,56 @@
"""Projects domain: projects, their Git repositories and per-project credentials."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from app.domain.base import new_id, utcnow
@dataclass(slots=True, kw_only=True)
class Project:
organization_id: str
name: str
slug: str
description: str = ""
created_by_user_id: str | None = None
default_branch: str = "main"
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class ProjectGitRepo:
"""The Git repository attached to a project.
Named ``ProjectGitRepo`` rather than ``ProjectRepository`` on purpose: the
latter collided with the *infrastructure* repository that manages ``Project``
rows, and the shadowing silently broke every Git endpoint at runtime.
"""
project_id: str
provider: str = "gitea"
owner: str = ""
repo_name: str = ""
clone_url: str = ""
web_url: str = ""
default_branch: str = "main"
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def full_name(self) -> str:
return f"{self.owner}/{self.repo_name}"
@dataclass(slots=True, kw_only=True)
class ProjectGitCredential:
"""Per-project Git identity. The password is encrypted at rest and never
returned to the frontend."""
project_id: str
username: str
encrypted_password: str
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
+128
View File
@@ -0,0 +1,128 @@
"""Sandbox domain: templates, workspaces, sessions, commands, snapshots, limits.
The host machine must never execute tenant code. Every command flows through a
SandboxProviderPort adapter and is bounded by a timeout and an output cap.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
class SandboxProviderKind(StrEnum):
MOCK = "mock"
DAYTONA = "daytona"
E2B = "e2b"
class WorkspaceStatus(StrEnum):
PENDING = "pending"
READY = "ready"
STOPPED = "stopped"
DESTROYED = "destroyed"
FAILED = "failed"
class SessionStatus(StrEnum):
STARTING = "starting"
RUNNING = "running"
PAUSED = "paused"
STOPPED = "stopped"
FAILED = "failed"
class NetworkPolicy(StrEnum):
DENY_ALL = "deny_all"
ALLOWLIST = "allowlist"
OPEN = "open"
@dataclass(slots=True, kw_only=True)
class SandboxTemplate:
name: str
provider: str = SandboxProviderKind.MOCK
image: str = "python:3.13-slim"
description: str = ""
default_cpu: float = 1.0
default_memory_mb: int = 1024
default_disk_mb: int = 2048
id: str = field(default_factory=new_id)
is_system: bool = True
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Workspace:
organization_id: str
project_id: str
template_id: str
provider: str = SandboxProviderKind.MOCK
external_workspace_id: str | None = None
status: WorkspaceStatus = WorkspaceStatus.PENDING
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class SandboxSession:
workspace_id: str
external_session_id: str | None = None
status: SessionStatus = SessionStatus.STARTING
id: str = field(default_factory=new_id)
started_at: datetime = field(default_factory=utcnow)
stopped_at: datetime | None = None
last_active_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class CommandRun:
session_id: str
command: str
exit_code: int | None = None
stdout_ref: str = ""
stderr_ref: str = ""
duration_ms: int = 0
truncated: bool = False
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def succeeded(self) -> bool:
return self.exit_code == 0
@dataclass(slots=True, kw_only=True)
class Snapshot:
workspace_id: str
name: str
external_snapshot_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class PreviewUrl:
workspace_id: str
port: int
url: str
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class SandboxResourceLimit:
"""Limits are stored in policy, never trusted from the caller."""
cpu: float = 1.0
memory_mb: int = 1024
disk_mb: int = 2048
timeout_seconds: int = 120
max_output_bytes: int = 200_000
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
scope: str = "organization"
scope_id: str | None = None
id: str = field(default_factory=new_id)
+252
View File
@@ -0,0 +1,252 @@
"""Agent Workforce domain: employees, chat threads, tasks, runs and artifacts."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
from app.domain.identity.models import Role
class EmployeeStatus(StrEnum):
ACTIVE = "active"
PAUSED = "paused"
ARCHIVED = "archived"
class RoleKey(StrEnum):
MANAGER = "manager"
PLANNER = "planner"
DEVELOPER = "developer"
REVIEWER = "reviewer"
TESTER = "tester"
RESEARCHER = "researcher"
CONTENT_CREATOR = "content_creator"
SUPPORT = "support"
class TaskStatus(StrEnum):
NEW = "new"
TRIAGED = "triaged"
WAITING_APPROVAL = "waiting_approval"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled"
class RunStatus(StrEnum):
QUEUED = "queued"
PLANNING = "planning"
WAITING_APPROVAL = "waiting_approval"
RUNNING = "running"
TOOL_CALLING = "tool_calling"
SANDBOX_RUNNING = "sandbox_running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
TIMED_OUT = "timed_out"
TERMINAL_RUN_STATUSES: frozenset[RunStatus] = frozenset(
{RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TIMED_OUT}
)
TERMINAL_TASK_STATUSES: frozenset[TaskStatus] = frozenset(
{TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
)
#: Allowed run state transitions. Anything else raises in the application layer.
RUN_TRANSITIONS: dict[RunStatus, frozenset[RunStatus]] = {
RunStatus.QUEUED: frozenset({RunStatus.PLANNING, RunStatus.CANCELLED, RunStatus.FAILED}),
RunStatus.PLANNING: frozenset(
{RunStatus.RUNNING, RunStatus.WAITING_APPROVAL, RunStatus.FAILED, RunStatus.CANCELLED}
),
RunStatus.WAITING_APPROVAL: frozenset(
{RunStatus.RUNNING, RunStatus.CANCELLED, RunStatus.FAILED, RunStatus.TIMED_OUT}
),
RunStatus.RUNNING: frozenset(
{
RunStatus.TOOL_CALLING,
RunStatus.SANDBOX_RUNNING,
RunStatus.WAITING_APPROVAL,
RunStatus.COMPLETED,
RunStatus.FAILED,
RunStatus.CANCELLED,
RunStatus.TIMED_OUT,
}
),
RunStatus.TOOL_CALLING: frozenset(
{RunStatus.RUNNING, RunStatus.WAITING_APPROVAL, RunStatus.FAILED, RunStatus.TIMED_OUT}
),
RunStatus.SANDBOX_RUNNING: frozenset(
{RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TIMED_OUT}
),
RunStatus.COMPLETED: frozenset(),
RunStatus.FAILED: frozenset(),
RunStatus.CANCELLED: frozenset(),
RunStatus.TIMED_OUT: frozenset(),
}
def can_transition_run(current: RunStatus, target: RunStatus) -> bool:
return target in RUN_TRANSITIONS.get(current, frozenset())
class MessageRole(StrEnum):
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
class ToolCallStatus(StrEnum):
PENDING = "pending"
AWAITING_APPROVAL = "awaiting_approval"
APPROVED = "approved"
REJECTED = "rejected"
SUCCEEDED = "succeeded"
FAILED = "failed"
@dataclass(slots=True, kw_only=True)
class AgentTemplate:
name: str
role_key: RoleKey
description: str = ""
default_prompt: str = ""
default_tools_json: list[str] = field(default_factory=list)
default_routing_policy_id: str | None = None
id: str = field(default_factory=new_id)
is_system: bool = True
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Employee:
organization_id: str
name: str
role_key: RoleKey
template_id: str | None = None
project_id: str | None = None
status: EmployeeStatus = EmployeeStatus.ACTIVE
routing_policy_id: str | None = None
budget_limit_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@property
def is_available(self) -> bool:
return self.status == EmployeeStatus.ACTIVE
@dataclass(slots=True, kw_only=True)
class PromptVersion:
employee_id: str
version: int
content: str
created_by_user_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class EmployeeToolPermission:
employee_id: str
tool_key: str
allowed: bool = False
policy_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
@dataclass(slots=True, kw_only=True)
class Thread:
organization_id: str
title: str = ""
project_id: str | None = None
created_by_user_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Message:
thread_id: str
role: MessageRole
content: str
employee_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Task:
organization_id: str
title: str
description: str = ""
project_id: str | None = None
thread_id: str | None = None
assigned_employee_id: str | None = None
created_by_user_id: str | None = None
status: TaskStatus = TaskStatus.NEW
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Run:
task_id: str
employee_id: str | None = None
status: RunStatus = RunStatus.QUEUED
error: str | None = None
id: str = field(default_factory=new_id)
started_at: datetime | None = None
finished_at: datetime | None = None
created_at: datetime = field(default_factory=utcnow)
@property
def is_terminal(self) -> bool:
return self.status in TERMINAL_RUN_STATUSES
@dataclass(slots=True, kw_only=True)
class RunStep:
run_id: str
index: int
kind: str
title: str = ""
payload_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class ToolCall:
run_id: str
tool_key: str
arguments_json: dict = field(default_factory=dict)
result_json: dict = field(default_factory=dict)
status: ToolCallStatus = ToolCallStatus.PENDING
requires_approval: bool = False
approved_by_user_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Artifact:
organization_id: str
name: str
kind: str = "file"
content_ref: str = ""
project_id: str | None = None
run_id: str | None = None
size_bytes: int = 0
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
#: Roles that may only be created by an org owner/admin.
ELEVATED_ROLE_KEYS: frozenset[RoleKey] = frozenset({RoleKey.MANAGER})
DEFAULT_EMPLOYEE_ROLE: Role = Role.MEMBER
+169
View File
@@ -0,0 +1,169 @@
"""Tool catalog for AI employees.
Three rules are encoded here as data rather than as scattered ``if`` statements:
1. Every tool has a stable key and an explicit approval requirement.
2. Dangerous tools require human approval **before** they run — the flag lives
with the tool, so a new call site cannot forget it.
3. Nothing is allowed by default; an employee needs an explicit permission row.
This module is pure: no I/O, no framework, no database.
"""
from __future__ import annotations
from dataclasses import dataclass
class ToolKey:
"""Stable tool identifiers. These appear in audit logs and permissions."""
GIT_ENSURE_REPO = "git.ensure_repo"
GIT_CREATE_BRANCH = "git.create_branch"
GIT_OPEN_PR = "git.open_pr"
GIT_MERGE_PR = "git.merge_pr"
GIT_DELETE_REPO = "git.delete_repo"
DOMAIN_CREATE_PREVIEW = "domain.create_preview"
DOMAIN_DELETE_PREVIEW = "domain.delete_preview"
SANDBOX_RUN_COMMAND = "sandbox.run_command"
SANDBOX_READ_FILE = "sandbox.read_file"
SANDBOX_WRITE_FILE = "sandbox.write_file"
SANDBOX_DELETE_FILE = "sandbox.delete_file"
SANDBOX_DESTROY = "sandbox.destroy"
KNOWLEDGE_SEARCH = "knowledge.search"
ARTIFACT_CREATE = "artifact.create"
@dataclass(frozen=True, slots=True)
class ToolSpec:
key: str
label: str
description: str
#: Human approval is required before this tool may execute.
requires_approval: bool = False
#: Irreversible or destructive: never auto-approved, always audited.
destructive: bool = False
#: Tools that touch the outside world are the ones worth approving.
category: str = "general"
TOOL_CATALOG: tuple[ToolSpec, ...] = (
ToolSpec(
key=ToolKey.GIT_ENSURE_REPO,
label="ساخت مخزن",
description="ساخت مخزن خصوصی Git برای پروژه.",
category="git",
),
ToolSpec(
key=ToolKey.GIT_CREATE_BRANCH,
label="ساخت شاخه",
description="ساخت یک شاخهٔ جدید از شاخهٔ مشخص.",
category="git",
),
ToolSpec(
key=ToolKey.GIT_OPEN_PR,
label="باز کردن درخواست ادغام",
description="ثبت یک PR از شاخهٔ کاری به شاخهٔ پایه.",
category="git",
),
ToolSpec(
key=ToolKey.GIT_MERGE_PR,
label="ادغام درخواست ادغام",
description="ادغام PR در شاخهٔ پایه. بازگشت‌ناپذیر.",
requires_approval=True,
destructive=True,
category="git",
),
ToolSpec(
key=ToolKey.GIT_DELETE_REPO,
label="حذف مخزن",
description="حذف کامل مخزن پروژه. بازگشت‌ناپذیر.",
requires_approval=True,
destructive=True,
category="git",
),
ToolSpec(
key=ToolKey.DOMAIN_CREATE_PREVIEW,
label="ساخت دامنهٔ تست",
description="انتشار موقت پروژه روی یک زیردامنه.",
category="domain",
),
ToolSpec(
key=ToolKey.DOMAIN_DELETE_PREVIEW,
label="حذف دامنهٔ تست",
description="برداشتن دامنهٔ تست از دسترس.",
requires_approval=True,
category="domain",
),
ToolSpec(
key=ToolKey.SANDBOX_RUN_COMMAND,
label="اجرای دستور در سندباکس",
description="اجرای یک دستور داخل محیط ایزوله با محدودیت زمان و خروجی.",
category="sandbox",
),
ToolSpec(
key=ToolKey.SANDBOX_READ_FILE,
label="خواندن فایل",
description="خواندن محتوای یک فایل در سندباکس.",
category="sandbox",
),
ToolSpec(
key=ToolKey.SANDBOX_WRITE_FILE,
label="نوشتن فایل",
description="نوشتن یا بازنویسی یک فایل در سندباکس.",
category="sandbox",
),
ToolSpec(
key=ToolKey.SANDBOX_DELETE_FILE,
label="حذف فایل",
description="حذف یک فایل از سندباکس. بازگشت‌ناپذیر.",
requires_approval=True,
destructive=True,
category="sandbox",
),
ToolSpec(
key=ToolKey.SANDBOX_DESTROY,
label="نابودی سندباکس",
description="پایان دادن به نشست سندباکس و آزادسازی منابع.",
requires_approval=True,
destructive=True,
category="sandbox",
),
ToolSpec(
key=ToolKey.KNOWLEDGE_SEARCH,
label="جست‌وجو در دانش",
description="جست‌وجو در پایگاه دانش سازمان.",
category="knowledge",
),
ToolSpec(
key=ToolKey.ARTIFACT_CREATE,
label="ساخت خروجی",
description="ثبت یک سند یا خروجی قابل مشاهده برای کاربر.",
category="artifact",
),
)
_BY_KEY: dict[str, ToolSpec] = {spec.key: spec for spec in TOOL_CATALOG}
#: Templates that reference an unknown tool key would silently grant nothing;
#: this set lets a test assert the seed data and the catalog agree.
KNOWN_TOOL_KEYS: frozenset[str] = frozenset(_BY_KEY)
def get_tool_spec(key: str) -> ToolSpec | None:
return _BY_KEY.get(key)
def is_known_tool(key: str) -> bool:
return key in _BY_KEY
def tool_requires_approval(key: str) -> bool:
"""Unknown tools are treated as requiring approval, never as safe."""
spec = _BY_KEY.get(key)
return True if spec is None else spec.requires_approval
def is_destructive(key: str) -> bool:
spec = _BY_KEY.get(key)
return False if spec is None else spec.destructive
@@ -0,0 +1,326 @@
"""Caddy adapter for :class:`DomainRouterPort`.
Publishes tenant preview domains by maintaining a *managed snippet file* that the
main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited
by this adapter.
Why a snippet instead of editing the Caddyfile
---------------------------------------------
Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not
own; a mistake there takes down every site on the host. A separate file that
Caddy imports by glob keeps the blast radius to tenant preview routes only.
Setup (one line, once)
----------------------
Add this to the Caddyfile and reload Caddy::
import /etc/caddy/agy-preview/*.caddy
The adapter refuses to run if that import is missing, because silently writing a
file that Caddy never reads would report success for a domain that does not
resolve.
Security rules enforced here, not only upstream
-----------------------------------------------
* the host must be a single label under ``PREVIEW_ROOT_DOMAIN``
(:func:`validate_managed_host`) — this is what stops a crafted host from
closing the site block and injecting directives
* the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`)
— SSRF prevention
* protected platform ports are refused (:func:`assert_not_protected_port`)
* the rendered config is validated *before* it is loaded, and every failed
change is rolled back to the previous file
The ``caddy`` binary runs on the platform host. That is a different trust domain
from tenant code: tenant commands only ever run inside a sandbox adapter.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from app.application.ports import PreviewDomainInfo, ReverseProxyResult
from app.core.errors import ProviderUnavailableError
from app.domain.platform.rules import (
RuleViolation,
assert_not_protected_port,
assert_port_allocatable,
build_host,
validate_managed_host,
validate_proxy_target,
)
_BEGIN = "# >>> agy-managed: {host}"
_END = "# <<< agy-managed: {host}"
_BLOCK_RE = re.compile(
r"# >>> agy-managed: (?P<host>\S+)\n(?P<body>.*?)# <<< agy-managed: (?P=host)\n",
re.DOTALL,
)
_VALIDATE_TIMEOUT_SECONDS = 15
_RELOAD_TIMEOUT_SECONDS = 30
class CaddyDomainRouterAdapter:
"""Reverse-proxy routes managed through a Caddy snippet file."""
def __init__(
self,
*,
caddyfile_path: str,
root_domain: str,
port_start: int = 8080,
port_end: int = 8999,
snippet_dir: str | None = None,
caddy_binary: str = "caddy",
) -> None:
self.caddyfile_path = Path(caddyfile_path)
self.root_domain = root_domain
self.port_start = port_start
self.port_end = port_end
self.caddy_binary = caddy_binary
# Default to a sibling directory of the Caddyfile so the import glob is
# a short, predictable path.
self.snippet_dir = (
Path(snippet_dir)
if snippet_dir
else self.caddyfile_path.parent / "agy-preview"
)
self.snippet_path = self.snippet_dir / "preview.caddy"
# -- port allocation -----------------------------------------------------
async def allocate_port(
self, *, organization_id: str, project_id: str, service_name: str
) -> int:
"""Pick the lowest free port that is not already routed.
The database is the authoritative port ledger
(``PreviewDomainRepository``); this only avoids handing out a port that a
live route already occupies.
"""
in_use = {entry.port for entry in self._parse(self._read_snippet())}
for port in range(self.port_start, self.port_end + 1):
if port in in_use:
continue
try:
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
except RuleViolation:
continue
return port
raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیین‌شده وجود ندارد.")
async def claim_preview_domain(
self,
*,
organization_id: str,
project_id: str,
requested_subdomain: str,
port: int,
) -> PreviewDomainInfo:
host = build_host(requested_subdomain, self.root_domain)
result = await self.configure_reverse_proxy(host=host, port=port)
if not result.ok:
raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.")
return PreviewDomainInfo(host=host, port=port, url=result.url, status="active")
# -- routing -------------------------------------------------------------
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
"""Create or replace the route for ``host`` and reload Caddy."""
try:
clean_host = validate_managed_host(host, self.root_domain)
# Raises unless the target is loopback with a numeric port.
validate_proxy_target(f"localhost:{port}")
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
assert_not_protected_port(port)
except RuleViolation as exc:
# A rejected rule is a caller error, not an outage.
return ReverseProxyResult(ok=False, message=str(exc))
if not self._import_is_wired():
return ReverseProxyResult(
ok=False,
message=(
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
),
)
previous = self._read_snippet()
entries = [e for e in self._parse(previous) if e.host != clean_host]
entries.append(_Entry(host=clean_host, port=port))
updated = self._render(entries)
self._write_snippet(updated)
error = await self._validate()
if error is None:
error = await self._reload()
if error is not None:
# Roll back so a failed change never leaves a half-written config.
self._write_snippet(previous)
await self._reload()
return ReverseProxyResult(ok=False, message=error)
return ReverseProxyResult(
ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}"
)
async def remove_preview_domain(self, *, domain_id: str) -> None:
"""Remove a route.
``domain_id`` is the host, matching how the application layer calls this
(``remove_preview_domain(domain_id=record["host"])``).
"""
try:
clean_host = validate_managed_host(domain_id, self.root_domain)
except RuleViolation:
# Not one of ours — nothing to remove, and not an error.
return
previous = self._read_snippet()
entries = [e for e in self._parse(previous) if e.host != clean_host]
if len(entries) == len(self._parse(previous)):
return
self._write_snippet(self._render(entries))
error = await self._validate()
if error is None:
error = await self._reload()
if error is not None:
self._write_snippet(previous)
await self._reload()
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
"""Every managed route in the snippet.
NOT tenant-scoped: the snippet records hosts and ports, not projects, and
``configure_reverse_proxy`` is not given a project id. The database
(``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger —
never use this method for authorization.
"""
return [
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
for e in self._parse(self._read_snippet())
]
# -- file handling -------------------------------------------------------
def _read_snippet(self) -> str:
if not self.snippet_path.exists():
return ""
return self.snippet_path.read_text(encoding="utf-8")
def _write_snippet(self, content: str) -> None:
"""Write atomically: a reader must never see a partial file."""
try:
self.snippet_dir.mkdir(parents=True, exist_ok=True)
tmp = self.snippet_path.with_suffix(".tmp")
tmp.write_text(content, encoding="utf-8")
tmp.replace(self.snippet_path)
except OSError as exc:
raise ProviderUnavailableError(
f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}"
) from exc
def _import_is_wired(self) -> bool:
"""Confirm the Caddyfile actually imports our snippet."""
try:
text = self.caddyfile_path.read_text(encoding="utf-8")
except OSError:
return False
needle = str(self.snippet_dir)
return any(
line.strip().startswith("import") and needle in line
for line in text.splitlines()
)
# -- rendering and parsing ----------------------------------------------
@staticmethod
def _render(entries: list[_Entry]) -> str:
header = (
"# This file is generated and owned by AGY (web preview domains).\n"
"# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n"
)
blocks = []
for entry in sorted(entries, key=lambda e: e.host):
blocks.append(
f"{_BEGIN.format(host=entry.host)}\n"
f"{entry.host} {{\n"
f"\treverse_proxy localhost:{entry.port}\n"
f"}}\n"
f"{_END.format(host=entry.host)}\n"
)
return header + "\n".join(blocks)
@staticmethod
def _parse(text: str) -> list[_Entry]:
entries: list[_Entry] = []
for match in _BLOCK_RE.finditer(text or ""):
port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body"))
if not port_match:
continue
entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1))))
return entries
# -- caddy process -------------------------------------------------------
async def _run(self, *args: str, timeout: int) -> tuple[int, str]:
"""Run the ``caddy`` binary. Platform process, never tenant code."""
try:
process = await asyncio.create_subprocess_exec(
self.caddy_binary,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise ProviderUnavailableError(
f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است."
) from exc
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except TimeoutError:
process.kill()
await process.wait()
return 1, "عملیات Caddy زمان‌بر شد."
combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip()
return process.returncode or 0, combined
async def _validate(self) -> str | None:
"""Return an error message, or ``None`` when the config is valid."""
code, output = await self._run(
"validate", "--config", str(self.caddyfile_path),
timeout=_VALIDATE_TIMEOUT_SECONDS,
)
if code != 0:
return f"پیکربندی Caddy نامعتبر است: {output[:400]}"
return None
async def _reload(self) -> str | None:
code, output = await self._run(
"reload", "--config", str(self.caddyfile_path),
timeout=_RELOAD_TIMEOUT_SECONDS,
)
if code != 0:
return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}"
return None
class _Entry:
"""One managed route."""
__slots__ = ("host", "port")
def __init__(self, *, host: str, port: int) -> None:
self.host = host
self.port = port
def __eq__(self, other: object) -> bool:
return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)
@@ -0,0 +1,412 @@
"""Gitea adapter for :class:`GitProviderPort`.
Talked to over the HTTP API with an admin token. The token is held by this
adapter only and is never placed on an object that a response could serialise.
Design decisions
----------------
**The adapter is stateless.** It is handed ``owner`` / ``repo_name`` by the
application layer instead of keeping a ``project_id`` map. A map would be lost on
restart and could drift from ``ProjectRepository``, so the database stays the one
source of truth for repository identity.
**Repositories live in one namespace, credentials are per project.** All
repositories are created under a single owner (the configured admin user) so
tenant repos cannot collide, and every project gets its *own* Gitea user that is
added as a collaborator on that one repository. A leaked clone credential
therefore reaches exactly one repository — least privilege rather than a shared
platform token.
**Names are re-validated here.** The application layer already produces safe
slugs, but the adapter must not depend on a caller behaving well: an unvalidated
name is the classic path to a path-traversal or an unintended API path segment.
Reference: ``docs/current-infra-notes.md`` §4.
"""
from __future__ import annotations
import base64
import re
import secrets
from urllib.parse import quote
import httpx
from app.application.ports import (
BranchInfo,
CommitInfo,
GitCredentials,
MergeResult,
PullRequestInfo,
RepoInfo,
RepoStatus,
)
from app.core.errors import ProviderUnavailableError, ValidationError
#: Gitea accepts ``[a-zA-Z0-9._-]`` for repository names; we are stricter and
#: keep the platform's lowercase-slug convention.
_REPO_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
#: Gitea usernames: alphanumeric plus ``-``/``_``, at most 40 characters, and
#: they may not start or end with a separator.
_USERNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]{1,38}[a-z0-9])$")
DEFAULT_TIMEOUT_SECONDS = 20.0
#: Cloning/committing can be slower than a metadata read.
WRITE_TIMEOUT_SECONDS = 60.0
class GiteaGitAdapter:
"""Git provider backed by a Gitea instance."""
def __init__(
self,
*,
base_url: str,
admin_user: str,
admin_token: str,
owner: str | None = None,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
if not base_url:
raise ProviderUnavailableError(
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_INTERNAL_URL یا GITEA_URL است."
)
if not admin_token:
raise ProviderUnavailableError(
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_ADMIN_TOKEN است."
)
self.base_url = base_url.rstrip("/")
self.admin_user = admin_user
self._admin_token = admin_token
#: Namespace that owns every project repository.
self.owner = owner or admin_user
self.timeout_seconds = timeout_seconds
# -- plumbing ------------------------------------------------------------
def _client(self, *, write: bool = False) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url,
headers={
"Authorization": f"token {self._admin_token}",
"Accept": "application/json",
},
timeout=WRITE_TIMEOUT_SECONDS if write else self.timeout_seconds,
)
@staticmethod
def _sanitize_repo_name(repo_name: str) -> str:
clean = (repo_name or "").strip().lower()
if not _REPO_NAME_RE.match(clean):
raise ValidationError(
"نام مخزن نامعتبر است؛ فقط حروف کوچک، عدد، نقطه، خط تیره و زیرخط مجاز است."
)
return clean
@staticmethod
def _sanitize_username(username: str) -> str:
clean = (username or "").strip().lower()
if not _USERNAME_RE.match(clean):
raise ValidationError("نام کاربری مخزن نامعتبر است.")
return clean
@staticmethod
def _seg(value: str) -> str:
"""Percent-encode a path segment so it cannot break out of the path."""
return quote(value, safe="")
async def _send(
self,
method: str,
path: str,
*,
write: bool = False,
expect: tuple[int, ...] = (200, 201),
**kwargs,
) -> httpx.Response:
"""Perform a request and translate transport failures.
A network problem must surface as ``ProviderUnavailableError`` (a 503),
never as a raw ``httpx`` exception leaking through the API.
"""
try:
async with self._client(write=write) as client:
response = await client.request(method, path, **kwargs)
except httpx.TimeoutException as exc:
raise ProviderUnavailableError("ارتباط با Gitea زمان‌بر شد.") from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
if response.status_code not in expect:
raise ProviderUnavailableError(
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {method} {path}."
)
return response
async def _get_json(self, path: str, *, expect: tuple[int, ...] = (200,)) -> dict | list | None:
"""GET that tolerates 404 by returning ``None``."""
try:
async with self._client() as client:
response = await client.get(path)
except httpx.HTTPError as exc:
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
if response.status_code == 404:
return None
if response.status_code not in expect:
raise ProviderUnavailableError(
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {path}."
)
return response.json()
# -- repository lifecycle ------------------------------------------------
async def ensure_project_repo(
self, *, project_id: str, repo_name: str, description: str = ""
) -> RepoInfo:
name = self._sanitize_repo_name(repo_name)
owner = self._sanitize_username(self.owner)
path = f"/api/v1/repos/{self._seg(owner)}/{self._seg(name)}"
existing = await self._get_json(path)
if existing is None:
# The admin endpoint creates the repository inside the owner's
# namespace, which a plain user token could not do.
await self._send(
"POST",
f"/api/v1/admin/users/{self._seg(owner)}/repos",
json={
"name": name,
"description": description,
"private": True,
"auto_init": True,
"default_branch": "main",
},
)
existing = await self._get_json(path)
if existing is None:
raise ProviderUnavailableError("ساخت مخزن در Gitea تأیید نشد.")
if not isinstance(existing, dict):
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
return self._to_repo_info(existing, fallback_name=name, fallback_owner=owner)
def _to_repo_info(self, payload: dict, *, fallback_name: str, fallback_owner: str) -> RepoInfo:
owner = ((payload.get("owner") or {}).get("login")) or fallback_owner
name = payload.get("name") or fallback_name
return RepoInfo(
owner=owner,
repo_name=name,
clone_url=payload.get("clone_url") or f"{self.base_url}/{owner}/{name}.git",
web_url=payload.get("html_url") or f"{self.base_url}/{owner}/{name}",
default_branch=payload.get("default_branch") or "main",
# Never let a remote payload make a tenant repository public.
private=True,
)
async def ensure_credentials(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> GitCredentials:
"""Create (or reset) a dedicated Gitea user for this project.
The user is added as a *collaborator on one repository only*, so the
returned password cannot reach any other tenant's code.
"""
repo_owner = self._sanitize_username(owner or self.owner)
name = self._sanitize_repo_name(repo_name)
username = self._sanitize_username(f"proj-{project_id[:24]}")
# A long random password, generated fresh every call. Gitea hashes it.
password = secrets.token_urlsafe(24)
email = f"{username}@users.noreply.agy.invalid"
created = await self._send(
"POST",
"/api/v1/admin/users",
expect=(200, 201, 422),
json={
"username": username,
"email": email,
"password": password,
"must_change_password": False,
"visibility": "private",
},
)
if created.status_code == 422:
# Already exists — rotate its password instead of failing, so a
# retried `ensure_credentials` stays idempotent.
await self._send(
"PATCH",
f"/api/v1/admin/users/{self._seg(username)}",
json={"password": password, "must_change_password": False},
)
# Grant write access to exactly this repository.
await self._send(
"PUT",
f"/api/v1/repos/{self._seg(repo_owner)}/{self._seg(name)}"
f"/collaborators/{self._seg(username)}",
json={"permission": "write"},
)
return GitCredentials(username=username, password=password)
# -- branches and commits ------------------------------------------------
async def create_branch(
self,
*,
project_id: str,
branch_name: str,
from_branch: str,
owner: str = "",
repo_name: str = "",
) -> BranchInfo:
base_path = self._repo_path(owner, repo_name)
response = await self._send(
"POST",
f"{base_path}/branches",
expect=(200, 201),
json={"new_branch_name": branch_name, "old_branch_name": from_branch},
)
payload = response.json()
commit = payload.get("commit") or {}
return BranchInfo(name=payload.get("name", branch_name), sha=commit.get("id", ""))
async def commit_files(
self,
*,
project_id: str,
branch_name: str,
files: dict[str, str],
message: str,
owner: str = "",
repo_name: str = "",
) -> CommitInfo:
"""Commit every file, one Contents-API call each.
Gitea's contents endpoint handles a single file per request, so a
multi-file change is a sequence of commits. Each update needs the blob's
current ``sha``, which we read first.
"""
base_path = self._repo_path(owner, repo_name)
last_sha = ""
changed = 0
for raw_path, content in files.items():
path = self._safe_content_path(raw_path)
encoded = quote(path, safe="/")
existing = await self._get_json(f"{base_path}/contents/{encoded}?ref={branch_name}")
body = {
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"message": message,
"branch": branch_name,
}
if isinstance(existing, dict) and existing.get("sha"):
body["sha"] = existing["sha"]
response = await self._send(
"PUT", f"{base_path}/contents/{encoded}", write=True, json=body
)
else:
response = await self._send(
"POST", f"{base_path}/contents/{encoded}", write=True, json=body
)
payload = response.json()
last_sha = (payload.get("commit") or {}).get("sha") or last_sha
changed += 1
return CommitInfo(
sha=last_sha, branch=branch_name, message=message, files_changed=changed
)
@staticmethod
def _safe_content_path(raw_path: str) -> str:
"""Reject absolute paths and traversal before they reach the URL."""
clean = (raw_path or "").strip().lstrip("/")
if not clean:
raise ValidationError("مسیر فایل نامعتبر است.")
segments = clean.split("/")
if any(seg in {"", ".", ".."} for seg in segments):
raise ValidationError("مسیر فایل نامعتبر است.")
return "/".join(segments)
# -- pull requests -------------------------------------------------------
async def open_pull_request(
self,
*,
project_id: str,
head: str,
base: str,
title: str,
body: str = "",
owner: str = "",
repo_name: str = "",
) -> PullRequestInfo:
response = await self._send(
"POST",
f"{self._repo_path(owner, repo_name)}/pulls",
expect=(200, 201),
json={"head": head, "base": base, "title": title, "body": body},
)
payload = response.json()
return PullRequestInfo(
number=int(payload.get("number", 0)),
url=payload.get("html_url", ""),
state=payload.get("state", "open"),
)
async def merge_pull_request(
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
) -> MergeResult:
try:
await self._send(
"POST",
f"{self._repo_path(owner, repo_name)}/pulls/{int(pr_number)}/merge",
json={"Do": "merge"},
)
except ProviderUnavailableError as exc:
# A merge conflict is a normal outcome, not an outage.
return MergeResult(merged=False, message=str(exc))
return MergeResult(merged=True, message="PR ادغام شد.")
# -- status --------------------------------------------------------------
async def get_repo_status(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> RepoStatus:
base_path = self._repo_path(owner, repo_name)
repo = await self._get_json(base_path)
if repo is None:
return RepoStatus(exists=False)
if not isinstance(repo, dict):
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
branches: list[str] = []
branch_payload = await self._get_json(f"{base_path}/branches")
if isinstance(branch_payload, list):
branches = sorted(b.get("name", "") for b in branch_payload if b.get("name"))
open_prs = 0
pr_payload = await self._get_json(f"{base_path}/pulls?state=open&limit=50")
if isinstance(pr_payload, list):
open_prs = len(pr_payload)
return RepoStatus(
exists=True,
private=True,
default_branch=repo.get("default_branch") or "main",
branches=branches,
open_pull_requests=open_prs,
)
# -- helpers -------------------------------------------------------------
def _repo_path(self, owner: str, repo_name: str) -> str:
"""Build ``/api/v1/repos/{owner}/{repo}`` from validated segments."""
if not repo_name:
raise ValidationError("نام مخزن مشخص نیست.")
clean_owner = self._sanitize_username(owner or self.owner)
clean_repo = self._sanitize_repo_name(repo_name)
return f"/api/v1/repos/{self._seg(clean_owner)}/{self._seg(clean_repo)}"
@@ -0,0 +1,520 @@
"""Mock adapters — every port has one so the stack runs with zero credentials.
These are not toys: they enforce the same contracts (timeouts, output caps,
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
change application behaviour.
"""
from __future__ import annotations
import asyncio
import hashlib
import secrets
import time
import uuid
from collections.abc import Iterable
from typing import Any
from app.application.ports import (
BranchInfo,
ChatMessage,
CheckoutSession,
CommandResult,
CommitInfo,
GitCredentials,
MergeResult,
ModelHealth,
ModelResponse,
Notification,
PaymentResult,
PreviewDomainInfo,
PullRequestInfo,
RepoInfo,
RepoStatus,
ReverseProxyResult,
SessionInfo,
SnapshotInfo,
StoredObject,
VectorHit,
WorkspaceInfo,
)
from app.domain.ai_control.models import AiModel
from app.domain.platform.rules import (
assert_port_allocatable,
build_host,
validate_managed_host,
validate_proxy_target,
)
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class MockModelProviderAdapter:
"""Deterministic, offline stand-in for an LLM.
Returns a canned Persian acknowledgement that names the task so tests can
assert on it without a network call.
"""
def __init__(self, latency_ms: int = 0) -> None:
self._latency_ms = latency_ms
async def complete(
self,
*,
model: AiModel,
credential=None,
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
started = time.perf_counter()
if self._latency_ms:
await asyncio.sleep(self._latency_ms / 1000)
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
prompt_tokens = sum(len(m.content.split()) for m in messages)
# The canned text must read like model output, not like a debug tag: the
# provider/model identity is platform-internal and must never surface in
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
content = (
"این پاسخ آزمایشی است. "
f"درخواست دریافتی: {last_user[:280] or '—'}"
)
return ModelResponse(
content=content,
model_internal_name=model.internal_name,
input_tokens=prompt_tokens + len(system.split()),
output_tokens=len(content.split()),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"mock": True},
)
async def health(self) -> ModelHealth:
return ModelHealth(ok=True, detail="mock provider")
# ---------------------------------------------------------------------------
# Git
# ---------------------------------------------------------------------------
class MockGitAdapter:
"""In-memory Git provider. Nothing touches a real forge.
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
adapter but ignored here — this adapter keys everything by ``project_id``.
"""
def __init__(self) -> None:
self._repos: dict[str, RepoInfo] = {}
self._credentials: dict[str, GitCredentials] = {}
self._branches: dict[str, dict[str, str]] = {}
self._prs: dict[str, list[PullRequestInfo]] = {}
async def ensure_project_repo(
self, *, project_id: str, repo_name: str, description: str = ""
) -> RepoInfo:
if project_id not in self._repos:
self._repos[project_id] = RepoInfo(
owner="mock",
repo_name=repo_name,
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
web_url=f"https://git.example.invalid/mock/{repo_name}",
default_branch="main",
private=True,
)
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
self._prs[project_id] = []
return self._repos[project_id]
async def ensure_credentials(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> GitCredentials:
if project_id not in self._credentials:
self._credentials[project_id] = GitCredentials(
username=f"proj_{project_id[:8]}",
password=secrets.token_urlsafe(12),
)
return self._credentials[project_id]
async def create_branch(
self,
*,
project_id: str,
branch_name: str,
from_branch: str,
owner: str = "",
repo_name: str = "",
) -> BranchInfo:
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
base = branches.get(from_branch, "0" * 40)
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
branches[branch_name] = sha
return BranchInfo(name=branch_name, sha=sha)
async def commit_files(
self,
*,
project_id: str,
branch_name: str,
files: dict[str, str],
message: str,
owner: str = "",
repo_name: str = "",
) -> CommitInfo:
digest = hashlib.sha1(
f"{branch_name}:{message}:{sorted(files)}".encode()
).hexdigest()[:40]
self._branches.setdefault(project_id, {})[branch_name] = digest
return CommitInfo(
sha=digest, branch=branch_name, message=message, files_changed=len(files)
)
async def open_pull_request(
self,
*,
project_id: str,
head: str,
base: str,
title: str,
body: str = "",
owner: str = "",
repo_name: str = "",
) -> PullRequestInfo:
prs = self._prs.setdefault(project_id, [])
number = len(prs) + 1
repo = self._repos.get(project_id)
web = repo.web_url if repo else "https://git.example.invalid/mock"
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
prs.append(pr)
return pr
async def merge_pull_request(
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
) -> MergeResult:
prs = self._prs.setdefault(project_id, [])
for pr in prs:
if pr.number == pr_number:
pr.state = "merged"
return MergeResult(merged=True, message="ادغام شد (mock).")
return MergeResult(merged=False, message="PR یافت نشد.")
async def get_repo_status(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> RepoStatus:
repo = self._repos.get(project_id)
if repo is None:
return RepoStatus(exists=False)
return RepoStatus(
exists=True,
private=repo.private,
default_branch=repo.default_branch,
branches=sorted(self._branches.get(project_id, {}).keys()),
open_pull_requests=sum(
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
),
)
# ---------------------------------------------------------------------------
# Domain router
# ---------------------------------------------------------------------------
class MockDomainRouterAdapter:
"""In-memory port + subdomain ledger.
Applies the *same* validation rules as the real Caddy adapter, so tests that
pass here describe behaviour the production adapter also guarantees.
"""
def __init__(
self,
*,
root_domain: str = "msa.artacloud.ir",
port_start: int = 8080,
port_end: int = 8999,
) -> None:
self.root_domain = root_domain
self.port_start = port_start
self.port_end = port_end
self._ports: dict[tuple[str, str], int] = {}
self._domains: dict[str, PreviewDomainInfo] = {}
self._project_domains: dict[str, list[str]] = {}
async def allocate_port(
self, *, organization_id: str, project_id: str, service_name: str
) -> int:
key = (project_id, service_name)
if key in self._ports:
return self._ports[key]
used = set(self._ports.values())
for port in range(self.port_start, self.port_end + 1):
if port in used:
continue
try:
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
except Exception:
continue
self._ports[key] = port
return port
raise RuntimeError("No available ports in the configured range.")
async def claim_preview_domain(
self,
*,
organization_id: str,
project_id: str,
requested_subdomain: str,
port: int,
) -> PreviewDomainInfo:
host = build_host(requested_subdomain, self.root_domain)
if host in self._domains:
owner_domains = self._project_domains.get(project_id, [])
if host not in owner_domains:
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
info = PreviewDomainInfo(
host=host, port=port, url=f"https://{host}", status="active"
)
self._domains[host] = info
self._project_domains.setdefault(project_id, []).append(host)
return info
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
validate_managed_host(host, self.root_domain)
validate_proxy_target(f"localhost:{port}")
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
async def remove_preview_domain(self, *, domain_id: str) -> None:
for host, info in list(self._domains.items()):
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
self._domains.pop(host, None)
self._domains.pop(domain_id, None)
for hosts in self._project_domains.values():
if domain_id in hosts:
hosts.remove(domain_id)
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
return [
self._domains[h]
for h in self._project_domains.get(project_id, [])
if h in self._domains
]
# ---------------------------------------------------------------------------
# Sandbox
# ---------------------------------------------------------------------------
class LocalMockSandboxAdapter:
"""Executes nothing. Returns plausible results and records commands.
``run_command`` still honours the timeout and output cap contract so callers
cannot depend on behaviour the real adapters will not provide.
"""
def __init__(self, max_output_bytes: int = 200_000) -> None:
self._workspaces: dict[str, str] = {}
self._sessions: dict[str, str] = {}
self._files: dict[str, dict[str, str]] = {}
self.commands: list[str] = []
self.max_output_bytes = max_output_bytes
async def create_workspace(
self, *, organization_id: str, project_id: str, template_id: str, image: str
) -> WorkspaceInfo:
external_id = f"ws_{uuid.uuid4().hex[:12]}"
self._workspaces[external_id] = image
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
external_id = f"ses_{uuid.uuid4().hex[:12]}"
self._sessions[external_id] = workspace_external_id
self._files.setdefault(external_id, {})
return SessionInfo(external_id=external_id, status="running")
async def run_command(
self,
*,
session_external_id: str,
command: str,
timeout_seconds: int,
max_output_bytes: int | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
limit = max_output_bytes or self.max_output_bytes
started = time.perf_counter()
self.commands.append(command)
stdout = f"[mock] اجرا شد: {command}"
truncated = len(stdout.encode()) > limit
if truncated:
stdout = stdout.encode()[:limit].decode(errors="ignore")
return CommandResult(
exit_code=0,
stdout=stdout,
stderr="",
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
truncated=truncated,
)
async def read_file(self, *, session_external_id: str, path: str) -> str:
return self._files.get(session_external_id, {}).get(path, "")
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
self._files.setdefault(session_external_id, {})[path] = content
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
async def pause(self, *, session_external_id: str) -> None:
return None
async def resume(self, *, session_external_id: str) -> SessionInfo:
return SessionInfo(external_id=session_external_id, status="running")
async def destroy(self, *, session_external_id: str) -> None:
self._sessions.pop(session_external_id, None)
self._files.pop(session_external_id, None)
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
return f"http://127.0.0.1:{port}"
# ---------------------------------------------------------------------------
# Payment
# ---------------------------------------------------------------------------
class LocalMockPaymentAdapter:
async def create_checkout(
self,
*,
organization_id: str,
amount: float,
currency: str,
success_url: str,
cancel_url: str,
) -> CheckoutSession:
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
return CheckoutSession(
external_id=external_id,
url=f"{success_url}?mock_checkout={external_id}",
amount=amount,
currency=currency,
)
async def refund(
self, *, external_payment_id: str, amount: float | None = None
) -> PaymentResult:
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
# ---------------------------------------------------------------------------
# Storage / vector / notification
# ---------------------------------------------------------------------------
class InMemoryStorageAdapter:
def __init__(self) -> None:
self._objects: dict[str, bytes] = {}
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
self._objects[key] = data
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
async def get(self, *, key: str) -> bytes:
return self._objects.get(key, b"")
async def delete(self, *, key: str) -> None:
self._objects.pop(key, None)
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
return f"memory://{key}?expires={expires_seconds}"
class InMemoryVectorStoreAdapter:
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
def __init__(self) -> None:
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
async def upsert(
self,
*,
organization_id: str,
chunks: Iterable[tuple[str, str, list[float]]],
metadata: dict | None = None,
) -> int:
count = 0
for chunk_id, content, _embedding in chunks:
document_id = str((metadata or {}).get("document_id", ""))
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
count += 1
return count
async def search(
self,
*,
organization_id: str,
query: str,
limit: int = 5,
project_id: str | None = None,
) -> list[VectorHit]:
terms = {t for t in query.lower().split() if t}
scored: list[VectorHit] = []
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
if org != organization_id:
continue
if project_id and meta.get("project_id") not in (None, project_id):
continue
overlap = len(terms & set(content.lower().split()))
if overlap:
scored.append(
VectorHit(
chunk_id=chunk_id,
document_id=document_id,
content=content,
score=float(overlap),
metadata=meta,
)
)
scored.sort(key=lambda h: h.score, reverse=True)
return scored[:limit]
async def delete_document(self, *, document_id: str) -> None:
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
if doc_id == document_id:
self._chunks.pop(chunk_id, None)
class ConsoleNotificationAdapter:
"""Records notifications instead of sending them."""
def __init__(self) -> None:
self.sent: list[Notification] = []
async def send(self, *, notification: Notification) -> bool:
self.sent.append(notification)
return True
class InMemoryAuditAdapter:
"""In-memory AuditPort, used by unit tests that do not want a database."""
def __init__(self) -> None:
# Records whatever the caller hands over; the port is untyped here.
self.events: list[Any] = []
async def record(self, *, event):
self.events.append(event)
return event
async def list_for_organization(
self, *, organization_id: str, limit: int = 100, offset: int = 0
):
matching = [e for e in self.events if e.organization_id == organization_id]
return matching[offset : offset + limit]
@@ -0,0 +1,97 @@
"""Real model adapter for any OpenAI-compatible endpoint.
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
API key is read from the credential record (decrypted by the caller), never from
the environment at request time.
"""
from __future__ import annotations
import time
import httpx
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
from app.core.errors import ProviderUnavailableError
from app.domain.ai_control.models import AiModel, ModelCredential
class OpenAICompatibleModelAdapter:
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
self.base_url = (base_url or "").rstrip("/")
self.default_api_key = default_api_key
self.timeout = timeout
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
if credential is not None and credential.encrypted_api_key:
try:
return decrypt(credential.encrypted_api_key)
except Exception: # noqa: BLE001 - fall through to the env default
pass
return self.default_api_key
async def complete(
self,
*,
model: AiModel,
credential: ModelCredential | None,
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
if not self.base_url:
raise ProviderUnavailableError(
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
)
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
{"role": m.role, "content": m.content} for m in messages
]
payload = {
"model": model.internal_name,
"messages": payload_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {"Content-Type": "application/json"}
api_key = self.default_api_key
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
started = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/chat/completions", json=payload, headers=headers
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
raise ProviderUnavailableError(
f"ارائه‌دهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
) from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("اتصال به ارائه‌دهندهٔ مدل برقرار نشد.") from exc
choice = (data.get("choices") or [{}])[0]
content = (choice.get("message") or {}).get("content", "") or ""
usage = data.get("usage") or {}
return ModelResponse(
content=content,
model_internal_name=data.get("model", model.internal_name),
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"id": data.get("id", "")},
)
async def health(self) -> ModelHealth:
if not self.base_url:
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{self.base_url}/models")
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
except httpx.HTTPError as exc:
return ModelHealth(ok=False, detail=str(exc))
@@ -0,0 +1,141 @@
"""Adapter registry.
One place decides which concrete adapter backs each port, driven purely by the
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
clone runs with no credentials.
Adapters that hold process state (the mock Git forge, the mock port ledger) are
cached as singletons so behaviour is consistent within a process.
"""
from __future__ import annotations
from functools import lru_cache
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.infrastructure.adapters import mock
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
@lru_cache
def get_model_provider():
provider = settings.ai_default_provider.lower()
if provider == "mock":
return mock.MockModelProviderAdapter()
if provider == "openai_compatible":
return OpenAICompatibleModelAdapter(
base_url=settings.openai_compatible_base_url,
default_api_key=settings.openai_compatible_api_key,
)
raise ProviderUnavailableError(
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمی‌شود. "
"مقادیر مجاز: mock | openai_compatible"
)
@lru_cache
def get_git_provider():
provider = settings.git_provider.lower()
if provider == "mock":
return mock.MockGitAdapter()
if provider == "gitea":
from app.infrastructure.adapters.gitea import GiteaGitAdapter
return GiteaGitAdapter(
base_url=settings.gitea_internal_url or settings.gitea_url,
admin_user=settings.gitea_admin_user,
admin_token=settings.gitea_admin_token,
)
raise ProviderUnavailableError(
f"GIT_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | gitea"
)
@lru_cache
def get_domain_router():
provider = settings.domain_router.lower()
if provider == "mock":
return mock.MockDomainRouterAdapter(
root_domain=settings.preview_root_domain,
port_start=settings.preview_port_start,
port_end=settings.preview_port_end,
)
if provider == "caddy":
from app.infrastructure.adapters.caddy import CaddyDomainRouterAdapter
return CaddyDomainRouterAdapter(
caddyfile_path=settings.caddyfile_path,
root_domain=settings.preview_root_domain,
port_start=settings.preview_port_start,
port_end=settings.preview_port_end,
)
raise ProviderUnavailableError(
f"DOMAIN_ROUTER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | caddy"
)
@lru_cache
def get_sandbox_provider():
provider = settings.sandbox_provider.lower()
if provider == "mock":
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
if provider in {"daytona", "e2b"}:
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
return build_remote_sandbox_adapter(
provider,
daytona_api_key=settings.daytona_api_key,
daytona_api_url=settings.daytona_api_url,
e2b_api_key=settings.e2b_api_key,
)
raise ProviderUnavailableError(
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | daytona | e2b"
)
@lru_cache
def get_payment_provider():
provider = settings.payment_provider.lower()
if provider == "mock":
return mock.LocalMockPaymentAdapter()
if provider == "stripe":
from app.infrastructure.adapters.stripe_payments import StripePaymentAdapter
return StripePaymentAdapter(
secret_key=settings.stripe_secret_key,
webhook_secret=settings.stripe_webhook_secret,
)
raise ProviderUnavailableError(
f"PAYMENT_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | stripe"
)
@lru_cache
def get_storage():
return mock.InMemoryStorageAdapter()
@lru_cache
def get_vector_store():
return mock.InMemoryVectorStoreAdapter()
@lru_cache
def get_notifier():
return mock.ConsoleNotificationAdapter()
def reset_adapters() -> None:
"""Drop cached singletons. Used by tests that need a clean slate."""
for fn in (
get_model_provider,
get_git_provider,
get_domain_router,
get_sandbox_provider,
get_payment_provider,
get_storage,
get_vector_store,
get_notifier,
):
fn.cache_clear()
@@ -0,0 +1,430 @@
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
This is the **only** place tenant code may execute. It never shells out on the
platform host: every command is sent to a remote, disposable sandbox through the
provider's official Python SDK.
Why the SDKs are optional extras
--------------------------------
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
the ``sandbox-remote`` extra so a default install stays small, and they are
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
Fail loudly, not silently
-------------------------
The two SDKs change their surface between minor releases. Rather than assume a
method exists and fail halfway through a customer's build, :meth:`preflight`
probes for every attribute this adapter relies on and refuses to construct the
adapter if any is missing. A version drift therefore surfaces as a clear
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
Known gap
---------
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
The mock provider implements snapshots fully, and the database already records
them, so only the remote path is affected.
"""
from __future__ import annotations
import time
from typing import Any
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
from app.core.errors import ProviderUnavailableError
#: Never let a caller ask for an unbounded run.
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
#: Mirror of the platform output cap; enforced client-side as well because a
#: remote SDK is under no obligation to truncate.
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
def _load_sdk(provider: str) -> Any:
"""Import the provider SDK, or explain exactly how to install it."""
try:
if provider == "daytona":
import daytona # noqa: PLC0415
return daytona
if provider == "e2b":
import e2b # noqa: PLC0415
return e2b
except ImportError as exc:
raise ProviderUnavailableError(
f"SDK «{provider}» نصب نیست. برای فعال‌سازی اجرا کنید: "
f"pip install 'agy-web-api[sandbox-remote]'"
) from exc
raise ProviderUnavailableError(f"ارائه‌دهندهٔ sandbox «{provider}» پشتیبانی نمی‌شود.")
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
if not hasattr(obj, attr):
raise ProviderUnavailableError(
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
"نسخهٔ پشتیبانی‌شده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
)
return getattr(obj, attr)
def _truncate(text: str, limit: int) -> tuple[str, bool]:
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= limit:
return text, False
return encoded[:limit].decode("utf-8", errors="ignore"), True
class _RemoteSandboxAdapter:
"""Shared behaviour for both remote providers."""
provider_name = "remote"
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
self._client: Any = None
self._sandboxes: dict[str, Any] = {}
self._sessions: dict[str, str] = {}
# -- lifecycle -----------------------------------------------------------
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
raise NotImplementedError
async def create_workspace(
self, *, organization_id: str, project_id: str, template_id: str, image: str
) -> WorkspaceInfo:
client = await self._ensure_client()
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
external_id = str(
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
)
self._sandboxes[external_id] = sandbox
return WorkspaceInfo(
external_id=external_id,
status="ready",
raw={"provider": self.provider_name, "image": image},
)
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
# Remote sandboxes are long-lived; a "session" is a handle onto one.
external_id = f"ses_{workspace_external_id}"
self._sessions[external_id] = workspace_external_id
return SessionInfo(external_id=external_id, status="running")
async def _sandbox_for_session(self, session_external_id: str) -> Any:
workspace_id = self._sessions.get(session_external_id, session_external_id)
sandbox = self._sandboxes.get(workspace_id)
if sandbox is None:
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
return sandbox
# -- execution -----------------------------------------------------------
async def run_command(
self,
*,
session_external_id: str,
command: str,
timeout_seconds: int,
max_output_bytes: int | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
sandbox = await self._sandbox_for_session(session_external_id)
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
# Clamp here too: the port contract says the caller can never ask for
# "no limit", and a bug upstream must not become an unbounded remote run.
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
started = time.perf_counter()
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
# Pull the three fields out defensively; a missing one is a version
# mismatch and must fail with a precise message.
exit_code = _require(
raw, "exit_code", provider=self.provider_name, where="command result"
)
raw_stdout = _require(
raw, "stdout", provider=self.provider_name, where="command result"
)
raw_stderr = _require(
raw, "stderr", provider=self.provider_name, where="command result"
)
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
return CommandResult(
exit_code=int(exit_code or 0),
stdout=stdout,
stderr=stderr,
duration_ms=duration_ms,
truncated=cut_out or cut_err,
)
# -- files ---------------------------------------------------------------
async def read_file(self, *, session_external_id: str, path: str) -> str:
sandbox = await self._sandbox_for_session(session_external_id)
return await self._read(sandbox, path)
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
sandbox = await self._sandbox_for_session(session_external_id)
await self._write(sandbox, path, content)
# -- snapshots / preview -------------------------------------------------
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
raise ProviderUnavailableError(
"snapshot بومی ارائه‌دهندهٔ راه‌دور هنوز فعال نشده است. "
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
"برای ارائه‌دهندهٔ خود پیاده‌سازی کنید."
)
async def pause(self, *, session_external_id: str) -> None:
sandbox = await self._sandbox_for_session(session_external_id)
await self._pause(sandbox)
async def resume(self, *, session_external_id: str) -> SessionInfo:
sandbox = await self._sandbox_for_session(session_external_id)
await self._resume(sandbox)
return SessionInfo(external_id=session_external_id, status="running")
async def destroy(self, *, session_external_id: str) -> None:
workspace_id = self._sessions.pop(session_external_id, session_external_id)
sandbox = self._sandboxes.pop(workspace_id, None)
if sandbox is not None:
await self._destroy(sandbox)
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
sandbox = await self._sandbox_for_session(session_external_id)
return await self._preview_url(sandbox, port)
# -- provider hooks ------------------------------------------------------
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
raise NotImplementedError
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
raise NotImplementedError
async def _read(self, sandbox: Any, path: str) -> str:
raise NotImplementedError
async def _write(self, sandbox: Any, path: str, content: str) -> None:
raise NotImplementedError
async def _pause(self, sandbox: Any) -> None:
raise NotImplementedError
async def _resume(self, sandbox: Any) -> None:
raise NotImplementedError
async def _destroy(self, sandbox: Any) -> None:
raise NotImplementedError
async def _preview_url(self, sandbox: Any, port: int) -> str:
raise NotImplementedError
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
"""Daytona Cloud sandboxes via the async SDK.
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
``client.delete(sandbox)``.
"""
provider_name = "daytona"
def __init__(
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
) -> None:
super().__init__(max_output_bytes=max_output_bytes)
if not api_key:
raise ProviderUnavailableError(
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
)
self._api_key = api_key
self._api_url = api_url
self._sdk = _load_sdk("daytona")
self._preflight()
def _preflight(self) -> None:
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
async def _ensure_client(self) -> Any:
if self._client is None:
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
if self._api_url:
config_kwargs["api_url"] = self._api_url
config = self._sdk.DaytonaConfig(**config_kwargs)
self._client = self._sdk.AsyncDaytona(config)
return self._client
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
# `create` accepts no arguments on the default image; a custom image is
# passed positionally as a snapshot/image reference.
try:
return await client.create()
except TypeError as exc:
raise ProviderUnavailableError(
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
f"{exc}. نسخهٔ پشتیبانی‌شدهٔ daytona را نصب کنید."
) from exc
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
process = _require(sandbox, "process", provider="daytona", where="sandbox")
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
response = await exec_fn(command, timeout=timeout)
# Daytona reports the exit code separately from the combined output.
result = str(
_require(response, "result", provider="daytona", where="process response") or ""
)
return _DaytonaCommandOutput(
exit_code=int(getattr(response, "exit_code", 0) or 0),
stdout=result,
stderr="",
)
async def _read(self, sandbox: Any, path: str) -> str:
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
content = await read_fn(path)
if isinstance(content, bytes):
return content.decode("utf-8", errors="replace")
return str(content or "")
async def _write(self, sandbox: Any, path: str, content: str) -> None:
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
await write_fn(content.encode("utf-8"), path)
async def _pause(self, sandbox: Any) -> None:
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
await stop_fn()
async def _resume(self, sandbox: Any) -> None:
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
await start_fn()
async def _destroy(self, sandbox: Any) -> None:
client = await self._ensure_client()
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
await delete_fn(sandbox)
async def _preview_url(self, sandbox: Any, port: int) -> str:
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
link = await link_fn(port)
return str(getattr(link, "url", link))
class E2BSandboxAdapter(_RemoteSandboxAdapter):
"""E2B sandboxes via the async SDK.
Targets the documented surface: ``AsyncSandbox.create(...)``,
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
``sandbox.kill()``.
"""
provider_name = "e2b"
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
super().__init__(max_output_bytes=max_output_bytes)
if not api_key:
raise ProviderUnavailableError(
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
)
self._api_key = api_key
self._sdk = _load_sdk("e2b")
self._preflight()
def _preflight(self) -> None:
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
async def _ensure_client(self) -> Any:
# E2B has no long-lived client object; the API key is passed per call.
return self._sdk.AsyncSandbox
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
return await create_fn(api_key=self._api_key)
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
result = await run_fn(command, timeout=timeout, envs=env or None)
return _E2BCommandOutput(
exit_code=int(getattr(result, "exit_code", 0) or 0),
stdout=str(getattr(result, "stdout", "") or ""),
stderr=str(getattr(result, "stderr", "") or ""),
)
async def _read(self, sandbox: Any, path: str) -> str:
files = _require(sandbox, "files", provider="e2b", where="sandbox")
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
content = await read_fn(path)
if isinstance(content, bytes):
return content.decode("utf-8", errors="replace")
return str(content or "")
async def _write(self, sandbox: Any, path: str, content: str) -> None:
files = _require(sandbox, "files", provider="e2b", where="sandbox")
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
await write_fn(path, content)
async def _pause(self, sandbox: Any) -> None:
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
await pause_fn()
async def _resume(self, sandbox: Any) -> None:
# E2B resumes by reconnecting to the sandbox id.
connect_fn = _require(
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
)
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
async def _destroy(self, sandbox: Any) -> None:
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
await kill_fn()
async def _preview_url(self, sandbox: Any, port: int) -> str:
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
return f"https://{await host_fn(port)}"
class _DaytonaCommandOutput:
__slots__ = ("exit_code", "stdout", "stderr")
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
class _E2BCommandOutput:
__slots__ = ("exit_code", "stdout", "stderr")
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
def build_remote_sandbox_adapter(
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
):
"""Factory used by the adapter registry."""
normalised = (provider or "").strip().lower()
if normalised == "daytona":
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
if normalised == "e2b":
return E2BSandboxAdapter(api_key=e2b_api_key)
raise ProviderUnavailableError(
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | daytona | e2b"
)
@@ -0,0 +1,203 @@
"""Stripe adapter for :class:`PaymentProviderPort`.
Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the
two calls we need are stable, and it keeps the API process free of an extra
dependency and its release cadence.
Responsibility boundary
-----------------------
The internal usage ledger is the **source of truth** for what a tenant owes.
This adapter only moves money — it never decides a balance, and a successful
checkout does not by itself credit a wallet. The application layer settles the
payment and then writes the credit, so a webhook replayed twice cannot
double-credit.
Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding,
never float multiplication, so ``19.99`` cannot become ``1998``.
"""
from __future__ import annotations
import hashlib
import hmac
import time
from decimal import ROUND_HALF_UP, Decimal
import httpx
from app.application.ports import CheckoutSession, PaymentResult
from app.core.errors import ProviderUnavailableError, ValidationError
STRIPE_API_BASE = "https://api.stripe.com/v1"
DEFAULT_TIMEOUT_SECONDS = 20.0
#: Stripe rejects signatures older than this; mirrored locally to fail early.
WEBHOOK_TOLERANCE_SECONDS = 300
#: Card networks require a minimum charge. Refuse early with a clear message
#: rather than letting Stripe return a confusing error.
MINIMUM_CHARGE_USD = Decimal("0.50")
class StripePaymentAdapter:
def __init__(
self,
*,
secret_key: str,
webhook_secret: str = "",
api_base: str = STRIPE_API_BASE,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
if not secret_key:
raise ProviderUnavailableError(
"PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است."
)
self._secret_key = secret_key
self._webhook_secret = webhook_secret
self.api_base = api_base.rstrip("/")
self.timeout_seconds = timeout_seconds
# -- port ----------------------------------------------------------------
async def create_checkout(
self,
*,
organization_id: str,
amount: float,
currency: str,
success_url: str,
cancel_url: str,
) -> CheckoutSession:
"""Create a one-off Checkout Session for a wallet top-up."""
minor_units = self._to_minor_units(amount)
currency_code = (currency or "usd").strip().lower()
form = {
"mode": "payment",
"success_url": success_url,
"cancel_url": cancel_url,
# Ties the Stripe object back to our tenant without exposing an id
# the customer can edit meaningfully.
"client_reference_id": organization_id,
"metadata[organization_id]": organization_id,
"line_items[0][quantity]": "1",
"line_items[0][price_data][currency]": currency_code,
"line_items[0][price_data][unit_amount]": str(minor_units),
"line_items[0][price_data][product_data][name]": "AGY credit top-up",
}
payload = await self._request("POST", "/checkout/sessions", data=form)
url = payload.get("url")
if not url:
raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.")
return CheckoutSession(
external_id=payload.get("id", ""),
url=url,
# Report back in major units so the caller never sees minor units.
amount=float(Decimal(payload.get("amount_total") or minor_units) / 100),
currency=(payload.get("currency") or currency_code).upper(),
)
async def refund(
self, *, external_payment_id: str, amount: float | None = None
) -> PaymentResult:
if not external_payment_id:
raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.")
form: dict[str, str] = {}
# A Checkout Session is not refundable directly; its PaymentIntent is.
# Accept either so a caller does not have to know Stripe's object graph.
if external_payment_id.startswith("cs_"):
session = await self._request(
"GET", f"/checkout/sessions/{external_payment_id}", params={}
)
intent = session.get("payment_intent")
if not intent:
return PaymentResult(
ok=False,
external_id=external_payment_id,
message="این پرداخت هنوز تکمیل نشده است.",
)
form["payment_intent"] = str(intent)
else:
form["payment_intent"] = external_payment_id
if amount is not None:
form["amount"] = str(self._to_minor_units(amount))
payload = await self._request("POST", "/refunds", data=form)
return PaymentResult(
ok=payload.get("status") in {"succeeded", "pending"},
external_id=payload.get("id", ""),
message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}",
)
# -- webhook -------------------------------------------------------------
def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool:
"""Verify a ``Stripe-Signature`` header over the raw request body.
The body must be the *raw bytes* — re-serialising parsed JSON changes the
byte sequence and the HMAC will never match.
"""
if not self._webhook_secret:
return False
timestamp = ""
signatures: list[str] = []
for part in (signature_header or "").split(","):
key, _, value = part.partition("=")
if key.strip() == "t":
timestamp = value.strip()
elif key.strip() == "v1":
signatures.append(value.strip())
if not timestamp or not signatures:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > WEBHOOK_TOLERANCE_SECONDS:
# Replay protection.
return False
signed_payload = f"{timestamp}.".encode() + payload
expected = hmac.new(
self._webhook_secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# Constant-time compare against every offered signature.
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
# -- helpers -------------------------------------------------------------
@staticmethod
def _to_minor_units(amount: float) -> int:
value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if value <= 0:
raise ValidationError("مبلغ باید بزرگ‌تر از صفر باشد.")
if value < MINIMUM_CHARGE_USD:
raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.")
return int(value * 100)
async def _request(self, method: str, path: str, **kwargs) -> dict:
headers = {"Authorization": f"Bearer {self._secret_key}"}
try:
async with httpx.AsyncClient(
base_url=self.api_base, timeout=self.timeout_seconds
) as client:
response = await client.request(method, path, headers=headers, **kwargs)
except httpx.TimeoutException as exc:
raise ProviderUnavailableError("ارتباط با Stripe زمان‌بر شد.") from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc
if response.status_code >= 400:
detail = ""
try:
detail = (response.json().get("error") or {}).get("message", "")
except ValueError:
detail = response.text[:200]
raise ProviderUnavailableError(
f"Stripe خطا برگرداند ({response.status_code}): {detail}"
)
return response.json()
+895
View File
@@ -0,0 +1,895 @@
"""SQLAlchemy ORM tables.
This is the *only* module that knows about the relational schema. The domain
layer is expressed as plain dataclasses in ``app.domain.*``; repositories map
between the two. Keeping the schema here means a migration change never forces
a change to domain logic.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.domain.base import new_id, utcnow
def _pk() -> Mapped[str]:
return mapped_column(String(32), primary_key=True, default=new_id)
def _created() -> Mapped[datetime]:
return mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
# ---------------------------------------------------------------------------
# Identity & Access
# ---------------------------------------------------------------------------
class UserRow(Base):
__tablename__ = "users"
id: Mapped[str] = _pk()
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
is_platform_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class OrganizationRow(Base):
__tablename__ = "organizations"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(160), nullable=False)
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
created_at: Mapped[datetime] = _created()
class MembershipRow(Base):
__tablename__ = "memberships"
__table_args__ = (
UniqueConstraint("organization_id", "user_id", name="uq_membership_org_user"),
Index("ix_membership_org_role", "organization_id", "role"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String(16), default="member", nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
created_at: Mapped[datetime] = _created()
class InviteRow(Base):
__tablename__ = "invites"
__table_args__ = (Index("ix_invite_org_email", "organization_id", "email"),)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
email: Mapped[str] = mapped_column(String(320), nullable=False)
role: Mapped[str] = mapped_column(String(16), default="member", nullable=False)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
invited_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# AI Control Plane (platform-owner only)
# ---------------------------------------------------------------------------
class AiProviderRow(Base):
__tablename__ = "ai_providers"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
type: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class AiModelCredentialRow(Base):
__tablename__ = "ai_model_credentials"
id: Mapped[str] = _pk()
provider_id: Mapped[str] = mapped_column(
ForeignKey("ai_providers.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class AiModelRow(Base):
__tablename__ = "ai_models"
__table_args__ = (
UniqueConstraint("provider_id", "internal_name", name="uq_model_provider_name"),
)
id: Mapped[str] = _pk()
provider_id: Mapped[str] = mapped_column(
ForeignKey("ai_providers.id", ondelete="CASCADE"), nullable=False, index=True
)
internal_name: Mapped[str] = mapped_column(String(160), nullable=False)
display_name_for_admin: Mapped[str] = mapped_column(String(160), nullable=False)
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
output_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class RoutingPolicyRow(Base):
__tablename__ = "routing_policies"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
mode: Mapped[str] = mapped_column(String(16), default="balanced", nullable=False)
fallback_policy_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
budget_limits_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class RateCardRow(Base):
__tablename__ = "rate_cards"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), nullable=False)
unit: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
customer_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
internal_cost_estimate: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# Projects & Git
# ---------------------------------------------------------------------------
class ProjectRow(Base):
__tablename__ = "projects"
__table_args__ = (
UniqueConstraint("organization_id", "slug", name="uq_project_org_slug"),
# The project list is always scoped to one organization and ordered by
# creation, so one composite index serves both the filter and the sort.
Index("ix_project_org_created", "organization_id", "created_at"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(160), nullable=False)
slug: Mapped[str] = mapped_column(String(64), nullable=False)
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
created_at: Mapped[datetime] = _created()
class ProjectRepositoryRow(Base):
__tablename__ = "project_repositories"
id: Mapped[str] = _pk()
project_id: Mapped[str] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String(32), default="gitea", nullable=False)
owner: Mapped[str] = mapped_column(String(120), default="", nullable=False)
repo_name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
clone_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
web_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False)
created_at: Mapped[datetime] = _created()
class ProjectGitCredentialRow(Base):
__tablename__ = "project_git_credentials"
id: Mapped[str] = _pk()
project_id: Mapped[str] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
)
username: Mapped[str] = mapped_column(String(160), nullable=False)
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# Preview domain routing
# ---------------------------------------------------------------------------
class PreviewPortRow(Base):
__tablename__ = "preview_ports"
__table_args__ = (
UniqueConstraint("project_id", "service_name", name="uq_preview_port_project_service"),
UniqueConstraint("port", name="uq_preview_port_port"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
service_name: Mapped[str] = mapped_column(String(80), default="web", nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
created_at: Mapped[datetime] = _created()
class PreviewDomainRow(Base):
__tablename__ = "preview_domains"
__table_args__ = (
UniqueConstraint("host", name="uq_preview_domain_host"),
Index("ix_preview_domain_org_created", "organization_id", "created_at"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# Sandbox
# ---------------------------------------------------------------------------
class SandboxTemplateRow(Base):
__tablename__ = "sandbox_templates"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
image: Mapped[str] = mapped_column(String(255), default="python:3.13-slim", nullable=False)
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
default_cpu: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
default_memory_mb: Mapped[int] = mapped_column(Integer, default=1024, nullable=False)
default_disk_mb: Mapped[int] = mapped_column(Integer, default=2048, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class WorkspaceRow(Base):
__tablename__ = "workspaces"
__table_args__ = (Index("ix_workspace_project_created", "project_id", "created_at"),)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
template_id: Mapped[str] = mapped_column(
ForeignKey("sandbox_templates.id"), nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
external_workspace_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
created_at: Mapped[datetime] = _created()
class SandboxSessionRow(Base):
__tablename__ = "sandbox_sessions"
id: Mapped[str] = _pk()
workspace_id: Mapped[str] = mapped_column(
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
)
external_session_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="starting", nullable=False)
started_at: Mapped[datetime] = _created()
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_active_at: Mapped[datetime] = _created()
class CommandRunRow(Base):
__tablename__ = "command_runs"
id: Mapped[str] = _pk()
session_id: Mapped[str] = mapped_column(
ForeignKey("sandbox_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
command: Mapped[str] = mapped_column(Text, nullable=False)
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
stdout_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
stderr_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
duration_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = _created()
class SnapshotRow(Base):
__tablename__ = "snapshots"
id: Mapped[str] = _pk()
workspace_id: Mapped[str] = mapped_column(
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
external_snapshot_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
created_at: Mapped[datetime] = _created()
class PreviewUrlRow(Base):
__tablename__ = "preview_urls"
id: Mapped[str] = _pk()
workspace_id: Mapped[str] = mapped_column(
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
)
port: Mapped[int] = mapped_column(Integer, nullable=False)
url: Mapped[str] = mapped_column(String(512), nullable=False)
created_at: Mapped[datetime] = _created()
class SandboxResourceLimitRow(Base):
__tablename__ = "sandbox_resource_limits"
__table_args__ = (UniqueConstraint("scope", "scope_id", name="uq_limit_scope"),)
id: Mapped[str] = _pk()
scope: Mapped[str] = mapped_column(String(32), default="organization", nullable=False)
scope_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
cpu: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
memory_mb: Mapped[int] = mapped_column(Integer, default=1024, nullable=False)
disk_mb: Mapped[int] = mapped_column(Integer, default=2048, nullable=False)
timeout_seconds: Mapped[int] = mapped_column(Integer, default=120, nullable=False)
max_output_bytes: Mapped[int] = mapped_column(Integer, default=200_000, nullable=False)
network_policy: Mapped[str] = mapped_column(String(16), default="deny_all", nullable=False)
# ---------------------------------------------------------------------------
# Workforce: templates, employees, chat, tasks, runs, artifacts
# ---------------------------------------------------------------------------
class AgentTemplateRow(Base):
__tablename__ = "agent_templates"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
role_key: Mapped[str] = mapped_column(String(32), nullable=False)
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
default_prompt: Mapped[str] = mapped_column(Text, default="", nullable=False)
default_tools_json: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
default_routing_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class EmployeeRow(Base):
__tablename__ = "employees"
__table_args__ = (Index("ix_employee_org_created", "organization_id", "created_at"),)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
)
template_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
role_key: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
routing_policy_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
budget_limit_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = _created()
class PromptVersionRow(Base):
__tablename__ = "prompt_versions"
__table_args__ = (
UniqueConstraint("employee_id", "version", name="uq_prompt_employee_version"),
)
id: Mapped[str] = _pk()
employee_id: Mapped[str] = mapped_column(
ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = _created()
class EmployeeToolPermissionRow(Base):
__tablename__ = "employee_tool_permissions"
__table_args__ = (
UniqueConstraint("employee_id", "tool_key", name="uq_employee_tool"),
)
id: Mapped[str] = _pk()
employee_id: Mapped[str] = mapped_column(
ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True
)
tool_key: Mapped[str] = mapped_column(String(80), nullable=False)
allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
policy_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
class ThreadRow(Base):
__tablename__ = "threads"
__table_args__ = (Index("ix_thread_org_created", "organization_id", "created_at"),)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = _created()
class MessageRow(Base):
__tablename__ = "messages"
# A conversation is always read as "one thread, oldest first", so the
# composite index serves the filter and the ordering in one lookup.
__table_args__ = (Index("ix_message_thread_created", "thread_id", "created_at"),)
id: Mapped[str] = _pk()
thread_id: Mapped[str] = mapped_column(
ForeignKey("threads.id", ondelete="CASCADE"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
created_at: Mapped[datetime] = _created()
class TaskRow(Base):
__tablename__ = "tasks"
__table_args__ = (
Index("ix_task_org_status", "organization_id", "status"),
Index("ix_task_org_created", "organization_id", "created_at"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
)
thread_id: Mapped[str | None] = mapped_column(
ForeignKey("threads.id", ondelete="SET NULL"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
status: Mapped[str] = mapped_column(String(24), default="new", nullable=False)
assigned_employee_id: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True
)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = _created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class RunRow(Base):
__tablename__ = "runs"
# A run has no organization_id of its own: it is scoped through its task,
# which is the object that carries the tenant. Indexing a column that does
# not exist here would be wrong, so the composite stays on task_id.
__table_args__ = (Index("ix_run_task_created", "task_id", "created_at"),)
id: Mapped[str] = _pk()
task_id: Mapped[str] = mapped_column(
ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True
)
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(24), default="queued", nullable=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = _created()
class RunStepRow(Base):
__tablename__ = "run_steps"
__table_args__ = (Index("ix_run_step_run_index", "run_id", "index"),)
id: Mapped[str] = _pk()
run_id: Mapped[str] = mapped_column(
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
)
index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
kind: Mapped[str] = mapped_column(String(40), default="note", nullable=False)
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
payload_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = _created()
class ToolCallRow(Base):
__tablename__ = "tool_calls"
__table_args__ = (
Index("ix_tool_call_run_created", "run_id", "created_at"),
# The pending-approval queue is "one organization, still awaiting a
# decision", which is a filter on status plus a join back to the run.
Index("ix_tool_call_status", "status"),
)
id: Mapped[str] = _pk()
run_id: Mapped[str] = mapped_column(
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
)
tool_key: Mapped[str] = mapped_column(String(80), nullable=False)
arguments_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
result_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
status: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
requires_approval: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
approved_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = _created()
class ArtifactRow(Base):
__tablename__ = "artifacts"
__table_args__ = (Index("ix_artifact_org_created", "organization_id", "created_at"),)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True
)
run_id: Mapped[str | None] = mapped_column(
ForeignKey("runs.id", ondelete="SET NULL"), nullable=True, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(32), default="file", nullable=False)
content_ref: Mapped[str] = mapped_column(Text, default="", nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# Billing & Usage
# ---------------------------------------------------------------------------
class PlanRow(Base):
__tablename__ = "plans"
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
monthly_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
included_credit: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
features_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
class SubscriptionRow(Base):
__tablename__ = "subscriptions"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
plan_id: Mapped[str] = mapped_column(ForeignKey("plans.id"), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(16), default="active", nullable=False)
external_subscription_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
current_period_end: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = _created()
class CreditWalletRow(Base):
__tablename__ = "credit_wallets"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), unique=True, nullable=False, index=True
)
balance: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
currency: Mapped[str] = mapped_column(String(8), default="USD", nullable=False)
low_balance_threshold: Mapped[float] = mapped_column(Float, default=5.0, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class UsageEventRow(Base):
__tablename__ = "usage_events"
__table_args__ = (
Index("ix_usage_org_created", "organization_id", "created_at"),
Index("ix_usage_org_unit", "organization_id", "unit"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
employee_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
unit: Mapped[str] = mapped_column(String(40), nullable=False)
quantity: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
unit_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
internal_cost_estimate: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
total_price: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = _created()
class InvoiceRow(Base):
__tablename__ = "invoices"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
currency: Mapped[str] = mapped_column(String(8), default="USD", nullable=False)
status: Mapped[str] = mapped_column(String(16), default="draft", nullable=False)
external_invoice_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = _created()
class PaymentRow(Base):
__tablename__ = "payments"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
provider: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
external_payment_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
invoice_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# Knowledge
# ---------------------------------------------------------------------------
class KnowledgeSourceRow(Base):
__tablename__ = "knowledge_sources"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(16), default="text", nullable=False)
uri: Mapped[str] = mapped_column(String(512), default="", nullable=False)
created_at: Mapped[datetime] = _created()
class DocumentRow(Base):
__tablename__ = "documents"
id: Mapped[str] = _pk()
source_id: Mapped[str] = mapped_column(
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), default="", nullable=False)
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
created_at: Mapped[datetime] = _created()
class DocumentChunkRow(Base):
__tablename__ = "document_chunks"
__table_args__ = (Index("ix_chunk_document_index", "document_id", "index"),)
id: Mapped[str] = _pk()
document_id: Mapped[str] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True
)
index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
content: Mapped[str] = mapped_column(Text, default="", nullable=False)
embedding_json: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
class EmbeddingJobRow(Base):
__tablename__ = "embedding_jobs"
id: Mapped[str] = _pk()
source_id: Mapped[str] = mapped_column(
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = _created()
class RetrievalPolicyRow(Base):
__tablename__ = "retrieval_policies"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
scope_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = _created()
# ---------------------------------------------------------------------------
# Audit & Observability
# ---------------------------------------------------------------------------
class AuditEventRow(Base):
__tablename__ = "audit_events"
__table_args__ = (
Index("ix_audit_org_created", "organization_id", "created_at"),
Index("ix_audit_action", "action"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
actor_user_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(80), nullable=False)
target_type: Mapped[str] = mapped_column(String(40), default="", nullable=False)
target_id: Mapped[str] = mapped_column(String(64), default="", nullable=False)
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
ip_address: Mapped[str] = mapped_column(String(64), default="", nullable=False)
user_agent: Mapped[str] = mapped_column(String(255), default="", nullable=False)
created_at: Mapped[datetime] = _created()
class TraceRow(Base):
__tablename__ = "traces"
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(160), default="", nullable=False)
created_at: Mapped[datetime] = _created()
class SpanRow(Base):
__tablename__ = "spans"
id: Mapped[str] = _pk()
trace_id: Mapped[str] = mapped_column(
ForeignKey("traces.id", ondelete="CASCADE"), nullable=False, index=True
)
parent_span_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
kind: Mapped[str] = mapped_column(String(32), default="internal", nullable=False)
attributes_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
started_at: Mapped[datetime] = _created()
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ErrorEventRow(Base):
"""Persisted failures — the table the error console reads.
Wide on purpose: a row that lacks the request path or the exception class
forces whoever is debugging to reproduce the bug to learn what it was. The
indexes cover the three access patterns — filter by tenant, filter by
"still open", and group by fingerprint.
"""
__tablename__ = "error_events"
__table_args__ = (
# Grouping view: "how often did this exact bug happen, and when last?"
Index("ix_error_events_fingerprint_created", "fingerprint", "created_at"),
# Feed view: "show me the newest failures for this tenant."
Index("ix_error_events_org_created", "organization_id", "created_at"),
)
id: Mapped[str] = _pk()
organization_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
run_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
kind: Mapped[str] = mapped_column(String(80), nullable=False)
message: Mapped[str] = mapped_column(Text, default="", nullable=False)
stack: Mapped[str] = mapped_column(Text, default="", nullable=False)
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
# --- classification -----------------------------------------------------
source: Mapped[str] = mapped_column(String(16), default="http", nullable=False)
severity: Mapped[str] = mapped_column(String(16), default="error", nullable=False)
error_type: Mapped[str] = mapped_column(String(200), default="", nullable=False)
#: Groups identical failures. Empty string rather than NULL so the grouping
#: index never has to deal with a NULL key.
fingerprint: Mapped[str] = mapped_column(String(64), default="", nullable=False, index=True)
# --- transport context --------------------------------------------------
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
method: Mapped[str] = mapped_column(String(10), default="", nullable=False)
path: Mapped[str] = mapped_column(String(512), default="", nullable=False)
# --- actor context ------------------------------------------------------
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
request_id: Mapped[str] = mapped_column(String(64), default="", nullable=False)
ip: Mapped[str] = mapped_column(String(64), default="", nullable=False)
user_agent: Mapped[str] = mapped_column(String(255), default="", nullable=False)
# --- triage -------------------------------------------------------------
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
resolved_note: Mapped[str] = mapped_column(Text, default="", nullable=False)
created_at: Mapped[datetime] = _created()
class QualityMetricRow(Base):
__tablename__ = "quality_metrics"
__table_args__ = (Index("ix_metric_org_name", "organization_id", "name"),)
id: Mapped[str] = _pk()
organization_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
value: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
dimensions_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = _created()
+281
View File
@@ -0,0 +1,281 @@
"""Persist failures so they can be diagnosed after the fact.
Three properties make this module different from an ordinary repository call,
and all three are load-bearing:
**It never raises.** This is called from inside exception handlers, workers and
startup code — the exact places where an exception is already in flight. A
recorder that throws turns a handled 500 into an unhandled one, or kills a
worker mid-batch. Every failure here is swallowed and reported on stderr.
**It uses its own session.** The caller's session is frequently unusable by the
time we get here: an ``IntegrityError`` leaves it in a failed transaction where
every subsequent statement raises ``PendingRollbackError``. Reusing it would
mean the errors hardest to record — the database errors — are exactly the ones
that never get recorded. So we open a short-lived session of our own, commit,
and close.
**It redacts before it writes.** Everything passes through
``app.core.redact`` first. Error payloads are where credentials concentrate.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import re
import sys
import traceback
from collections.abc import Mapping
from typing import Any
from app.core.database import SessionFactory
from app.core.redact import redact_mapping, redact_text
from app.domain.audit.models import (
ErrorEvent,
ErrorSeverity,
ErrorSource,
)
logger = logging.getLogger("agy.errors")
#: Field caps. An error log that accepts unbounded input is a denial-of-service
#: vector and a storage leak; a 2 MB stack trace helps nobody.
MAX_MESSAGE_CHARS = 4_000
MAX_STACK_CHARS = 16_000
MAX_METADATA_JSON_CHARS = 20_000
MAX_KIND_CHARS = 80
MAX_TYPE_CHARS = 200
MAX_PATH_CHARS = 512
# ---------------------------------------------------------------------------
# Fingerprinting — collapse a thousand occurrences into one row
# ---------------------------------------------------------------------------
_UUID_RE = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
_HEX_RE = re.compile(r"\b[0-9a-fA-F]{8,}\b")
_NUMBER_RE = re.compile(r"\b\d+\b")
_QUOTED_RE = re.compile(r"'[^']{0,200}'|\"[^\"]{0,200}\"")
#: A path segment that is an entity id rather than a route name. All-hex catches
#: ``new_id()`` (32 hex chars); the digit+length rule catches other id styles.
#: Route words like ``organizations`` have no digits and survive untouched.
_ID_SEGMENT_RE = re.compile(r"^(?:[0-9a-fA-F]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$")
def _normalise_message(message: str) -> str:
"""Strip the variable parts of a message so equal bugs hash equally.
``"task 4f3a… not found"`` and ``"task 9b1c… not found"`` are one bug with
two victims; without this they are two unrelated rows.
"""
text = _UUID_RE.sub("{uuid}", message)
text = _HEX_RE.sub("{hex}", text)
text = _QUOTED_RE.sub("{value}", text)
return _NUMBER_RE.sub("{n}", text)
def _normalise_path(path: str) -> str:
"""``/api/orgs/4f3a…/projects`` → ``/api/orgs/{id}/projects``."""
if not path:
return ""
return "/".join(
"{id}" if _ID_SEGMENT_RE.match(segment) else segment for segment in path.split("/")
)
def compute_fingerprint(
*,
kind: str,
error_type: str,
method: str = "",
path: str = "",
message: str = "",
) -> str:
"""Stable identity for a failure, independent of tenant and occurrence.
Deliberately *excludes* organization, user and request id: the same bug
hitting two tenants is still one bug, and grouping it that way is the whole
point of a fingerprint.
"""
material = "\x1f".join(
(
kind.strip().lower(),
error_type.strip().lower(),
method.strip().upper(),
_normalise_path(path).lower(),
_normalise_message(message.strip())[:500],
)
)
return hashlib.sha256(material.encode("utf-8", "replace")).hexdigest()[:32]
# ---------------------------------------------------------------------------
# Truncation helpers
# ---------------------------------------------------------------------------
def _clip(value: str, limit: int) -> str:
if len(value) <= limit:
return value
return value[:limit] + f"…[+{len(value) - limit} chars]"
def _safe_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Redact, then bound. Returns a dict that is always JSON-serialisable."""
if not metadata:
return {}
scrubbed = redact_mapping(metadata)
try:
encoded = json.dumps(scrubbed, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return {"_note": "metadata was not JSON-serialisable"}
if len(encoded) <= MAX_METADATA_JSON_CHARS:
return scrubbed
# Too large to keep: retain the keys so the shape is still visible.
return {
"_note": f"metadata dropped: {len(encoded)} chars exceeds {MAX_METADATA_JSON_CHARS}",
"_keys": sorted(str(k) for k in scrubbed)[:50],
}
def format_exception_stack(exc: BaseException) -> str:
"""``traceback.format_exception`` without the chained-context noise limit."""
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
def _to_stderr(event: ErrorEvent, reason: str) -> None:
"""Last-resort output when the database write failed.
Goes to stderr rather than through ``logging`` so it survives even a
misconfigured logging setup — the one case where losing the error entirely
is unacceptable.
"""
with contextlib.suppress(Exception):
sys.stderr.write(
f"[error-log] could not persist error ({reason}) "
f"kind={event.kind} type={event.error_type} "
f"path={event.path} msg={event.message[:300]}\n"
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def record_error(
*,
kind: str,
message: str,
exc: BaseException | None = None,
stack: str = "",
source: str = ErrorSource.HTTP,
severity: str = ErrorSeverity.ERROR,
status_code: int | None = None,
method: str = "",
path: str = "",
organization_id: str | None = None,
user_id: str | None = None,
run_id: str | None = None,
request_id: str = "",
ip: str = "",
user_agent: str = "",
metadata: Mapping[str, Any] | None = None,
) -> str | None:
"""Write one error row. Returns its id, or ``None`` if recording failed.
Returning ``None`` rather than raising is the contract: callers treat error
recording as best-effort telemetry, never as a step that can fail a request.
"""
try:
error_type = type(exc).__name__ if exc is not None else ""
if not stack and exc is not None:
stack = format_exception_stack(exc)
if not message and exc is not None:
message = str(exc) or error_type
# Redact *before* fingerprinting is not required, but redact before
# *storage* absolutely is. Fingerprint the raw message so a secret that
# happens to appear in a message does not change the grouping.
fingerprint = compute_fingerprint(
kind=kind, error_type=error_type, method=method, path=path, message=message
)
event = ErrorEvent(
kind=_clip(redact_text(kind), MAX_KIND_CHARS),
message=_clip(redact_text(message), MAX_MESSAGE_CHARS),
stack=_clip(redact_text(stack), MAX_STACK_CHARS),
metadata_json=_safe_metadata(metadata),
organization_id=organization_id,
user_id=user_id,
run_id=run_id,
source=source,
severity=severity,
error_type=_clip(redact_text(error_type), MAX_TYPE_CHARS),
fingerprint=fingerprint,
status_code=status_code,
method=_clip(method, 10),
path=_clip(redact_text(path), MAX_PATH_CHARS),
request_id=_clip(request_id, 64),
ip=_clip(redact_text(ip), 64),
user_agent=_clip(redact_text(user_agent), 255),
)
except Exception as build_exc: # pragma: no cover - redaction is defensive
logger.exception("error-log: could not build the error record: %s", build_exc)
return None
# A dedicated session: the caller's may be mid-rollback, and a failed error
# write must not roll back whatever the caller was legitimately doing.
try:
async with SessionFactory() as session:
from app.infrastructure.repositories.audit import ObservabilityRepository
await ObservabilityRepository(session).record_error(event)
await session.commit()
except Exception as write_exc:
_to_stderr(event, type(write_exc).__name__)
logger.warning("error-log: persistence failed: %s", write_exc)
return None
# Mirror to the application log so the console and the terminal agree.
logger.log(
logging.ERROR if severity != ErrorSeverity.WARNING else logging.WARNING,
"%s [%s] %s %s -> %s: %s (request_id=%s fingerprint=%s)",
severity.upper(),
source,
method or "-",
path or "-",
status_code if status_code is not None else "-",
event.message[:500],
request_id or "-",
fingerprint,
)
return event.id
async def record_exception(
exc: BaseException,
*,
kind: str | None = None,
**context: Any,
) -> str | None:
"""``record_error`` for the common case where there is an exception object."""
return await record_error(
kind=kind or type(exc).__name__,
message=str(exc),
exc=exc,
**context,
)
__all__ = [
"compute_fingerprint",
"format_exception_stack",
"record_error",
"record_exception",
]
@@ -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)
View File
+197
View File
@@ -0,0 +1,197 @@
"""FastAPI dependencies: authentication, tenant resolution and capability checks.
The single most important rule of this file: **every** organization-scoped
endpoint resolves its tenant through ``get_org_context``, which refuses to
return anything unless the caller has an active membership. There is no
"trust the org_id in the path" path.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.core.errors import AuthenticationError, NotFoundError, PermissionDeniedError
from app.core.security import decode_access_token
from app.domain.identity.models import Membership, Organization, Role, User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.request_context import note_actor
if TYPE_CHECKING:
from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService
from app.application.services.sandbox_service import SandboxService
from app.application.services.workforce_service import WorkforceService
_bearer = HTTPBearer(auto_error=False)
@dataclass(slots=True)
class OrgContext:
"""Resolved tenant context for an organization-scoped request."""
organization: Organization
membership: Membership
user: User
@property
def org_id(self) -> str:
return self.organization.id
def require(self, capability: str) -> None:
if not self.membership.has(capability):
raise PermissionDeniedError("شما دسترسی لازم برای این عملیات را ندارید.")
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
if credentials is None or not credentials.credentials:
raise AuthenticationError("برای این عملیات باید وارد شوید.")
payload = decode_access_token(credentials.credentials)
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("توکن نامعتبر است.")
user = await UserRepository(session).get(str(user_id))
if user is None or not user.is_active:
raise AuthenticationError("حساب کاربری یافت نشد یا غیرفعال است.")
# Record the verified actor so any later failure is attributable. Uses the
# id from the database, not the one in the token.
note_actor(request, user_id=user.id)
return user
async def get_optional_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User | None:
if credentials is None or not credentials.credentials:
return None
try:
return await get_current_user(request, credentials, session)
except AuthenticationError:
return None
async def require_platform_owner(user: User = Depends(get_current_user)) -> User:
if not user.is_platform_owner:
raise PermissionDeniedError("این بخش فقط برای مالک سامانه قابل دسترسی است.")
return user
async def get_org_context(
request: Request,
org_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> OrgContext:
"""Resolve ``org_id`` into an authorized tenant context.
Returns 404 (not 403) when the caller has no membership, so the API does not
leak the existence of other organizations.
"""
organization = await OrganizationRepository(session).get(org_id)
if organization is None:
raise NotFoundError("سازمان یافت نشد.")
membership = await MembershipRepository(session).get(org_id, user.id)
if membership is None or not membership.is_active:
raise NotFoundError("سازمان یافت نشد.")
# The tenant is only known *after* the membership check passes, so this is
# the earliest point at which it is safe to attribute a failure to an org.
note_actor(request, organization_id=organization.id, user_id=user.id)
return OrgContext(organization=organization, membership=membership, user=user)
def require_capability(capability: str):
"""Dependency factory: ``Depends(require_capability(CAN_MANAGE_PROJECTS))``."""
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
context.require(capability)
return context
return _checker
def require_role_at_least(minimum: Role):
from app.domain.identity.models import ROLE_RANK
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
if ROLE_RANK[context.membership.role] < ROLE_RANK[minimum]:
raise PermissionDeniedError("سطح دسترسی شما برای این عملیات کافی نیست.")
return context
return _checker
def client_meta(request: Request) -> tuple[str, str]:
"""``(ip, user_agent)`` for the audit trail."""
forwarded = request.headers.get("x-forwarded-for", "")
ip = (
forwarded.split(",")[0].strip()
if forwarded
else (request.client.host if request.client else "")
)
return ip, request.headers.get("user-agent", "")[:255]
# The builders themselves live in ``app.application.container`` so that the
# standalone workers compose services exactly the way the API does. These
# functions are thin FastAPI dependency wrappers around them.
async def get_project_service(
session: AsyncSession = Depends(get_session),
) -> ProjectService:
"""Build the project/domain service with the configured adapters."""
from app.application.container import build_project_service
return build_project_service(session)
async def get_workforce_service(
session: AsyncSession = Depends(get_session),
) -> WorkforceService:
"""Build the workforce/chat/run service with the configured adapters."""
from app.application.container import build_workforce_service
return build_workforce_service(session)
async def get_billing_service(
session: AsyncSession = Depends(get_session),
) -> BillingService:
"""Build the billing service with the configured payment adapter."""
from app.application.container import build_billing_service
return build_billing_service(session)
async def get_knowledge_service(
session: AsyncSession = Depends(get_session),
) -> KnowledgeService:
"""Build the knowledge service with the configured vector adapter."""
from app.application.container import build_knowledge_service
return build_knowledge_service(session)
async def get_sandbox_service(
session: AsyncSession = Depends(get_session),
) -> SandboxService:
"""Build the sandbox gateway with the configured execution adapter."""
from app.application.container import build_sandbox_service
return build_sandbox_service(session)
+213
View File
@@ -0,0 +1,213 @@
"""Translate domain errors into HTTP responses — and record them.
The inner layers never import FastAPI; this module is the only place that knows
about status codes. It is also the single choke point through which every
request-scoped failure passes, which makes it the right place to persist them.
What gets recorded, and why:
* **Unhandled exceptions and 5xx ``AppError``s** — always. A provider being down
or a quota check failing is an operational fault someone must see.
* **A bare ``ValueError`` escaping to the handler** — always, at ``warning``.
The codebase has a typed ``ValidationError`` for expected bad input and uses
it 30 times, so a bare ``ValueError`` reaching here is usually a typing gap
worth reviewing. The set of call sites is small and bounded.
* **Other 4xx** — only when ``ERROR_LOG_CLIENT_ERRORS`` is on. Most 4xx are a
caller sending bad input, and a public API receives a steady stream of 404s
from scanners; recording all of them by default would bury the real bugs.
Recorded at ``warning`` so they never look like faults.
Every error body carries the request id, and every response carries it in the
``X-Request-ID`` header, so a user can quote one string and land on exactly one
row in the console.
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.errors import AppError
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
from app.interfaces.http.request_context import REQUEST_ID_HEADER, context_of
logger = logging.getLogger("agy.api")
#: Statuses at or above this are "the server is broken".
SERVER_ERROR_THRESHOLD = 500
#: Cap on the per-field validation detail echoed back and stored. A caller
#: sending a 10 000-element array should not produce a 10 000-line error row.
MAX_VALIDATION_ERRORS = 20
def _request_id(request: Request) -> str:
context = context_of(request)
return context.request_id if context else ""
def _error_response(
*,
status_code: int,
code: str,
message: str,
request_id: str,
details: object = None,
) -> JSONResponse:
"""The one place an error response is built.
The request id goes in the header as well as the body, and it has to be set
*here* rather than by the middleware: an unhandled exception is turned into a
response by Starlette's ``ServerErrorMiddleware``, which sits outside every
user middleware, so the middleware never sees the response to add a header
to. The body alone would leave an HTTP client that only reads headers with
no way to correlate the failure.
"""
error: dict = {"code": code, "message": message, "request_id": request_id}
if details is not None:
error["details"] = details
headers = {REQUEST_ID_HEADER: request_id} if request_id else None
return JSONResponse(
status_code=status_code, content={"error": error}, headers=headers
)
async def _record(
request: Request,
*,
kind: str,
message: str,
status_code: int | None,
exc: BaseException | None = None,
severity: str = ErrorSeverity.ERROR,
metadata: dict | None = None,
) -> None:
"""Persist a request-scoped failure. Never raises."""
context = context_of(request)
try:
await record_error(
kind=kind,
message=message,
exc=exc,
source=ErrorSource.HTTP,
severity=severity,
status_code=status_code,
method=context.method if context else request.method,
path=context.path if context else request.url.path,
organization_id=context.organization_id if context else None,
user_id=context.user_id if context else None,
run_id=context.run_id if context else None,
request_id=context.request_id if context else "",
ip=context.ip if context else "",
user_agent=context.user_agent if context else "",
metadata=metadata,
)
except Exception: # pragma: no cover - the recorder is already total
logger.exception("error-log: unexpected failure while recording an error")
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
is_server_fault = exc.status_code >= SERVER_ERROR_THRESHOLD
if settings.error_log_enabled and (is_server_fault or settings.error_log_client_errors):
await _record(
request,
kind=exc.code,
message=exc.message,
status_code=exc.status_code,
# Pass the exception so the stack is captured: a
# ProviderUnavailable raised three layers down is far easier to
# fix with the traceback than with the message alone.
exc=exc,
severity=ErrorSeverity.ERROR if is_server_fault else ErrorSeverity.WARNING,
metadata={"details": exc.details} if exc.details is not None else None,
)
return _error_response(
status_code=exc.status_code,
code=exc.code,
message=exc.message,
request_id=_request_id(request),
details=exc.details,
)
@app.exception_handler(RequestValidationError)
async def _validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Reject malformed input in the standard envelope.
FastAPI's default body is ``{"detail": [...]}``, which the web client
cannot read — it would surface every validation failure as a generic
"server error (422)". Same status, useful shape.
"""
errors = [
{
"loc": [str(part) for part in err.get("loc", ())],
"msg": str(err.get("msg", "")),
}
for err in exc.errors()[:MAX_VALIDATION_ERRORS]
]
if settings.error_log_enabled and settings.error_log_client_errors:
await _record(
request,
kind="request_validation_error",
message="; ".join(f"{'.'.join(e['loc'])}: {e['msg']}" for e in errors),
status_code=422,
severity=ErrorSeverity.WARNING,
)
return _error_response(
status_code=422,
code="validation_error",
message="داده‌های ارسالی نامعتبر است.",
request_id=_request_id(request),
details=errors,
)
@app.exception_handler(ValueError)
async def _value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
if settings.error_log_enabled:
await _record(
request,
kind="value_error",
message=str(exc),
status_code=422,
exc=exc,
severity=ErrorSeverity.WARNING,
)
return _error_response(
status_code=422,
code="validation_error",
message=str(exc),
request_id=_request_id(request),
)
@app.exception_handler(Exception)
async def _unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
"""The last line of defence: an unexpected failure.
Recorded unconditionally when logging is enabled — this is precisely the
class of error the console exists for.
"""
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
if settings.error_log_enabled:
await _record(
request,
kind="unhandled_exception",
message=str(exc) or type(exc).__name__,
status_code=500,
exc=exc,
severity=ErrorSeverity.CRITICAL,
)
return _error_response(
status_code=500,
code="internal_error",
message="خطای داخلی سرور. لطفاً دوباره تلاش کنید.",
request_id=_request_id(request),
)
@@ -0,0 +1,139 @@
"""Per-request correlation and actor context.
Every request gets an id before it reaches a route. That id is returned in the
``X-Request-ID`` response header, included in every error body, and written onto
every error record — so a user reporting "it broke" can hand over one string
that points at exactly one row in the error console.
The same object carries the resolved tenant and user. Authentication happens in
dependencies, long after the middleware has run, so the middleware seeds the
context with what it knows (method, path, ip, user agent) and the auth
dependencies fill in the rest via :func:`note_actor`. Error handlers then read
one object instead of re-deriving anything.
Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is
the same object the handler already has in hand, it survives every task
boundary Starlette may introduce, and it cannot leak between requests.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
#: Header used to both accept a caller-supplied id and echo it back. Accepting
#: an upstream id matters when the API sits behind a reverse proxy or a
#: frontend BFF that already correlates its own logs.
REQUEST_ID_HEADER = "X-Request-ID"
#: Long enough to be unique, short enough to paste into a bug report.
REQUEST_ID_LENGTH = 16
_STATE_ATTR = "error_context"
@dataclass(slots=True)
class RequestContext:
"""What we know about the request an error happened on."""
request_id: str
method: str
path: str
ip: str = ""
user_agent: str = ""
#: Filled in by the auth dependencies once the caller is authenticated.
organization_id: str | None = None
user_id: str | None = None
#: Set when a failure happens inside a specific agent run.
run_id: str | None = None
#: Query string, kept separate from ``path`` so fingerprints group across
#: different filters on the same endpoint.
query: str = ""
def new_request_id() -> str:
return uuid.uuid4().hex[:REQUEST_ID_LENGTH]
def client_ip(request: Request) -> str:
"""Real client address, honouring one proxy hop.
Same rule as ``deps.client_meta``: a trusted reverse proxy sets
``X-Forwarded-For``, and the left-most entry is the original caller.
"""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else ""
def context_of(request: Request) -> RequestContext | None:
"""The context for this request, or ``None`` outside the middleware."""
return getattr(request.state, _STATE_ATTR, None)
def note_actor(
request: Request,
*,
user_id: str | None = None,
organization_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Record who/which tenant this request belongs to.
Called from the auth dependencies. Silent when there is no context (a
direct unit-test call to a dependency, for instance) — correlation is
telemetry, and telemetry must never be the reason a request fails.
"""
context = context_of(request)
if context is None:
return
if user_id:
context.user_id = user_id
if organization_id:
context.organization_id = organization_id
if run_id:
context.run_id = run_id
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Attach a :class:`RequestContext` to every request and echo its id."""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
incoming = request.headers.get(REQUEST_ID_HEADER, "").strip()
# Cap the accepted value: an unbounded header would let a caller write
# arbitrarily long ids into the error table.
request_id = incoming[:64] if incoming else new_request_id()
setattr( # noqa: B010 - the attribute name is a module constant
request.state,
_STATE_ATTR,
RequestContext(
request_id=request_id,
method=request.method,
path=request.url.path,
query=request.url.query or "",
ip=client_ip(request),
user_agent=request.headers.get("user-agent", "")[:255],
),
)
response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id
return response
__all__ = [
"REQUEST_ID_HEADER",
"RequestContext",
"RequestContextMiddleware",
"client_ip",
"context_of",
"new_request_id",
"note_actor",
]
@@ -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
],
)
+137
View File
@@ -0,0 +1,137 @@
"""FastAPI application factory.
Nothing in the inner layers imports FastAPI; this module wires them together.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import Base, engine
from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import (
admin,
auth,
billing,
health,
knowledge,
organizations,
projects,
sandbox,
workforce,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
logger = logging.getLogger("agy.api")
@asynccontextmanager
async def lifespan(_: FastAPI):
try:
# In development we create any missing tables so `pnpm dev` works on a
# fresh clone. Production schema changes go through Alembic
# (`pnpm db:migrate`).
if not settings.is_production:
import app.infrastructure.db.models # noqa: F401 - register metadata
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Development schema ensured.")
logger.info(
"Adapters -> ai=%s git=%s domain=%s sandbox=%s payment=%s",
settings.ai_default_provider,
settings.git_provider,
settings.domain_router,
settings.sandbox_provider,
settings.payment_provider,
)
except Exception as exc:
# Startup failures are the hardest to diagnose after the fact: the
# process is gone and all that remains is whatever reached stderr, which
# a container runtime routinely discards. Record it, then still fail
# loudly — a half-started API must not accept traffic.
await _record_startup_failure(exc)
raise
yield
async def _record_startup_failure(exc: BaseException) -> None:
"""Persist a boot failure. Never raises; the caller re-raises regardless."""
try:
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
await record_error(
kind="startup_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.STARTUP,
severity=ErrorSeverity.CRITICAL,
metadata={"app_env": settings.app_env},
)
except Exception: # pragma: no cover - boot is already failing
logger.exception("Could not record the startup failure.")
def create_app() -> FastAPI:
application = FastAPI(
title=settings.app_name,
version="0.1.0",
description="AI Workforce Platform — standalone multi-tenant SaaS.",
lifespan=lifespan,
docs_url="/docs",
openapi_url="/openapi.json",
)
# Starlette applies middleware outermost-first in reverse registration
# order, so the last one added wraps everything else. The request-context
# middleware goes last on purpose: that way even a CORS rejection gets an
# ``X-Request-ID`` — and a CORS rejection is exactly the kind of opaque
# failure where a correlatable id is worth having.
application.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# Without this the browser cannot read the header off a cross-origin
# response, and the id would be useless to the web client.
expose_headers=["X-Request-ID"],
)
application.add_middleware(RequestContextMiddleware)
register_exception_handlers(application)
application.include_router(health.router)
application.include_router(auth.router, prefix="/api")
application.include_router(organizations.router, prefix="/api")
application.include_router(projects.router, prefix="/api")
application.include_router(workforce.router, prefix="/api")
application.include_router(billing.router, prefix="/api")
application.include_router(knowledge.router, prefix="/api")
application.include_router(sandbox.router, prefix="/api")
application.include_router(admin.router, prefix="/api")
@application.get("/", tags=["meta"])
async def root() -> dict:
return {
"name": settings.app_name,
"version": "0.1.0",
"docs": "/docs",
"health": "/health",
}
return application
app = create_app()
+366
View File
@@ -0,0 +1,366 @@
"""Idempotent development seed.
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
organization that both of them own, the system agent templates, routing
policies, rate cards and a mock provider with two models.
Safe to run repeatedly: every step checks for existing rows first.
The two accounts it prints at the end are meant to be usable immediately —
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
login endpoint rejects is worse than no seed at all.
"""
from __future__ import annotations
import asyncio
import logging
from sqlalchemy import select
from app.core.database import Base, SessionFactory, engine
from app.core.security import hash_password
from app.domain.ai_control.models import (
AiModel,
Capability,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
RoutingPolicy,
)
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
from app.domain.workforce.models import AgentTemplate, RoleKey
from app.infrastructure.db.models import (
AgentTemplateRow,
AiModelRow,
AiProviderRow,
CreditWalletRow,
RateCardRow,
RoutingPolicyRow,
SandboxTemplateRow,
UserRow,
)
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
logger = logging.getLogger("agy.seed")
#: ``example.com`` is a documentation domain and passes email validation.
#: Do NOT use ``.local``/``.test``/``.invalid``: those are special-use TLDs and
#: ``email-validator`` rejects them, so the seed would create an account that the
#: API then refuses to log in — the user exists but is unreachable.
PLATFORM_OWNER_EMAIL = "owner@example.com"
PLATFORM_OWNER_PASSWORD = "owner12345"
DEMO_EMAIL = "demo@example.com"
DEMO_PASSWORD = "demo12345"
#: Stable identifiers for the sample organization. Both must stay constant for
#: the seed to be idempotent — see ``_ensure_org``.
DEMO_ORG_NAME = "سازمان نمونه"
DEMO_ORG_SLUG = "sample-org"
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
(
"مدیر سازمان", RoleKey.MANAGER,
"کارها را دسته‌بندی می‌کند و بین کارمندها تقسیم می‌کند.",
["knowledge.search", "artifact.create"],
),
(
"تحلیل‌گر و معمار", RoleKey.PLANNER,
"نیازمندی‌ها را تحلیل و پلن اجرایی می‌سازد.",
["knowledge.search"],
),
(
"برنامه‌نویس", RoleKey.DEVELOPER,
"کد می‌نویسد و در sandbox اجرا و تست می‌کند.",
["git.ensure_repo", "git.create_branch", "sandbox.run_command",
"sandbox.read_file", "sandbox.write_file"],
),
(
"بازبین کد", RoleKey.REVIEWER,
"PRها را بازبینی می‌کند و بازخورد می‌دهد.",
["git.open_pr"],
),
(
"تستر", RoleKey.TESTER,
"تست می‌نویسد و اجرا می‌کند.",
["sandbox.run_command", "sandbox.write_file"],
),
(
"محقق", RoleKey.RESEARCHER,
"منابع را جمع و خلاصه می‌کند.",
["knowledge.search", "artifact.create"],
),
(
"تولیدکننده محتوا", RoleKey.CONTENT_CREATOR,
"متن و محتوای سازمانی تولید می‌کند.",
["artifact.create"],
),
(
"پشتیبان مشتری", RoleKey.SUPPORT,
"به پرسش‌های مشتری پاسخ می‌دهد.",
["knowledge.search"],
),
]
ROUTING_POLICIES: list[tuple[str, RoutingMode, bool, str]] = [
("سریع و ارزان", RoutingMode.CHEAP, True, "برای کارهای روزمره و پرتکرار."),
("متعادل", RoutingMode.BALANCED, False, "پیش‌فرض متعادل بین هزینه و کیفیت."),
("استدلال عمیق", RoutingMode.REASONING, False, "برای تحلیل و کارهای پیچیده."),
("کدنویسی", RoutingMode.CODING, False, "بهینه برای تولید و اصلاح کد."),
("چندرسانه‌ای", RoutingMode.VISION, False, "برای ورودی تصویری."),
]
RATE_CARDS: list[tuple[RateUnit, float, float]] = [
(RateUnit.MODEL_INPUT_TOKENS, 0.0000004, 0.0000002),
(RateUnit.MODEL_OUTPUT_TOKENS, 0.0000016, 0.0000008),
(RateUnit.SANDBOX_CPU_SECOND, 0.00002, 0.00001),
(RateUnit.SANDBOX_MEMORY_GB_SECOND, 0.000004, 0.000002),
(RateUnit.STORAGE_GB_DAY, 0.0004, 0.0002),
(RateUnit.TOOL_CALL, 0.0005, 0.0001),
(RateUnit.PREMIUM_EMPLOYEE_RUN, 0.02, 0.008),
]
#: (name, image, description, cpu, memory_mb, disk_mb)
SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
(
"python-3.13", "python:3.13-slim",
"محیط پایتون برای اجرای تست و اسکریپت.", 1.0, 1024, 2048,
),
(
"node-22", "node:22-slim",
"محیط Node برای build و اجرای پروژه‌های فرانت‌اند.", 1.0, 2048, 4096,
),
(
"ubuntu-base", "ubuntu:24.04",
"محیط عمومی لینوکس برای ابزارهای خط فرمان.", 1.0, 1024, 2048,
),
]
async def _ensure_user(
session, *, email: str, password: str, name: str, platform_owner: bool
) -> User:
existing = (
await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one_or_none()
if existing:
return User(
id=existing.id, email=existing.email, password_hash=existing.password_hash,
name=existing.name, is_platform_owner=existing.is_platform_owner,
)
from app.infrastructure.repositories.identity import UserRepository
user = await UserRepository(session).create(
User(
email=email,
password_hash=hash_password(password),
name=name,
is_platform_owner=platform_owner,
)
)
logger.info("created user %s", email)
return user
async def _ensure_org(session, *, members: list[User]) -> Organization:
"""Get or create the sample organization, with every given user in it.
Idempotent in all three parts: the organization, its memberships and its
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with
exactly one organization and no duplicate rows.
"""
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
orgs = OrganizationRepository(session)
memberships = MembershipRepository(session)
# A *fixed* slug, not `slugify(name)`. The name is Persian, and `slugify`
# falls back to a random `x<hex>` when it cannot transliterate — so deriving
# the slug here would produce a different value on every run, `get_by_slug`
# would always miss, and re-seeding would silently create a duplicate
# organization (and a duplicate wallet) each time.
org = await orgs.get_by_slug(DEMO_ORG_SLUG)
if org is None:
org = await orgs.create(
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id)
)
logger.info("created organization %s", org.slug)
# Every listed user becomes an owner of the sample org. Without this the
# demo account can log in but belongs to no organization, so every screen
# renders empty and the account looks broken.
for user in members:
if await memberships.get(org.id, user.id) is None:
await memberships.add(
Membership(
organization_id=org.id, user_id=user.id,
role=Role.OWNER, status=MembershipStatus.ACTIVE,
)
)
logger.info("added %s to %s as owner", user.email, org.slug)
wallet = (
await session.execute(
select(CreditWalletRow).where(CreditWalletRow.organization_id == org.id)
)
).scalars().first()
if wallet is None:
session.add(CreditWalletRow(organization_id=org.id, balance=25.0, currency="USD"))
return org
async def seed() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionFactory() as session:
owner = await _ensure_user(
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
name="مالک سامانه", platform_owner=True,
)
demo = await _ensure_user(
session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
name="کاربر نمونه", platform_owner=False,
)
# Both accounts join the sample organization, so a developer who logs in
# as either one lands on a populated dashboard rather than an empty one.
await _ensure_org(session, members=[owner, demo])
if not (await session.execute(select(AiProviderRow))).scalars().first():
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
session.add(
AiProviderRow(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=True,
)
)
await session.flush()
for internal, display, caps in (
("mock-fast", "Mock Fast", [Capability.TEXT]),
(
"mock-reasoning",
"Mock Reasoning",
[Capability.TEXT, Capability.REASONING, Capability.CODE],
),
):
model = AiModel(
provider_id=provider.id,
internal_name=internal,
display_name_for_admin=display,
capabilities=[str(c) for c in caps],
input_price_per_1m=0.3,
output_price_per_1m=1.2,
)
session.add(
AiModelRow(
id=model.id, provider_id=model.provider_id,
internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=True,
)
)
logger.info("created mock provider + 2 models")
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
for name, role_key, description, tools in AGENT_TEMPLATES:
template = AgentTemplate(
name=name, role_key=role_key, description=description,
default_prompt=f"تو {name} هستی. وظیفه‌ات: {description}",
default_tools_json=tools,
)
session.add(
AgentTemplateRow(
id=template.id, name=template.name, role_key=template.role_key.value,
description=template.description, default_prompt=template.default_prompt,
default_tools_json=template.default_tools_json, is_system=True,
)
)
logger.info("created %d agent templates", len(AGENT_TEMPLATES))
if not (await session.execute(select(RoutingPolicyRow))).scalars().first():
for name, mode, is_default, description in ROUTING_POLICIES:
policy = RoutingPolicy(
name=name, mode=mode, description=description, is_default=is_default
)
session.add(
RoutingPolicyRow(
id=policy.id, name=policy.name, description=policy.description,
mode=policy.mode.value, is_default=policy.is_default, is_active=True,
)
)
logger.info("created %d routing policies", len(ROUTING_POLICIES))
if not (await session.execute(select(RateCardRow))).scalars().first():
for unit, customer_price, internal_cost in RATE_CARDS:
card = RateCard(
name=unit.value.replace("_", " "),
unit=unit,
customer_price=customer_price,
internal_cost_estimate=internal_cost,
)
session.add(
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=True,
)
)
logger.info("created %d rate cards", len(RATE_CARDS))
if not (await session.execute(select(SandboxTemplateRow))).scalars().first():
for name, image, description, cpu, memory_mb, disk_mb in SANDBOX_TEMPLATES:
# Named distinctly from the agent-template loop above so the two
# entities cannot be confused.
sandbox_template = SandboxTemplate(
name=name, provider=SandboxProviderKind.MOCK, image=image,
description=description, default_cpu=cpu,
default_memory_mb=memory_mb, default_disk_mb=disk_mb,
)
session.add(
SandboxTemplateRow(
id=sandbox_template.id, name=sandbox_template.name,
provider=str(sandbox_template.provider),
image=sandbox_template.image,
description=sandbox_template.description,
default_cpu=sandbox_template.default_cpu,
default_memory_mb=sandbox_template.default_memory_mb,
default_disk_mb=sandbox_template.default_disk_mb,
is_system=True,
)
)
logger.info("created %d sandbox templates", len(SANDBOX_TEMPLATES))
await session.commit()
logger.info("seed complete")
logger.info("platform owner: %s / %s", PLATFORM_OWNER_EMAIL, PLATFORM_OWNER_PASSWORD)
logger.info("demo user: %s / %s", DEMO_EMAIL, DEMO_PASSWORD)
if __name__ == "__main__":
try:
asyncio.run(seed())
except Exception as exc:
# A failed seed is almost always a first-run problem — the very moment
# there is nobody watching a terminal. Record it before exiting non-zero.
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
asyncio.run(
record_error(
kind="seed_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.STARTUP,
severity=ErrorSeverity.CRITICAL,
)
)
raise
+29
View File
@@ -0,0 +1,29 @@
"""Standalone workers.
Each worker is a thin driver over the application services. They are shipped as
modules inside the API package so they reuse the exact same composition
(``app.application.container``); ``services/*`` are the process-level shells
that launch them, ready to be split into separate deployments.
Run one with::
python -m app.workers billing
python -m app.workers ai-runtime
python -m app.workers sandbox-gateway
"""
from __future__ import annotations
from app.workers.ai_runtime import AiRuntimeWorker
from app.workers.base import WorkerOutcome, run_periodically, worker_session
from app.workers.billing import BillingWorker
from app.workers.sandbox_gateway import SandboxGatewayWorker
__all__ = [
"AiRuntimeWorker",
"BillingWorker",
"SandboxGatewayWorker",
"WorkerOutcome",
"run_periodically",
"worker_session",
]
+132
View File
@@ -0,0 +1,132 @@
"""CLI entry point for the workers.
python -m app.workers billing [--loop --interval 3600]
python -m app.workers ai-runtime [--loop --interval 30 --limit 10]
python -m app.workers sandbox-gateway [--loop --interval 600 --idle-minutes 120]
Default is **one pass and exit**, which is the shape a cron entry wants. Pass
``--loop`` for a long-running deployment; SIGINT/SIGTERM then stop it cleanly.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from app.workers.base import WorkerOutcome, install_signal_handlers, run_periodically
logger = logging.getLogger("agy.worker")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="app.workers", description="AGY background workers")
parser.add_argument(
"worker",
choices=["billing", "ai-runtime", "sandbox-gateway"],
help="which worker to run",
)
parser.add_argument("--loop", action="store_true", help="keep running instead of one pass")
parser.add_argument(
"--interval",
type=float,
default=300.0,
help="seconds between passes when --loop is set (default: 300)",
)
parser.add_argument("--limit", type=int, default=10, help="max rows per pass (default: 10)")
parser.add_argument(
"--idle-minutes",
type=int,
default=120,
help="sandbox-gateway: stop sessions idle for this long (default: 120)",
)
parser.add_argument(
"--period-days",
type=int,
default=30,
help="billing: length of the billing period (default: 30)",
)
return parser
def _billing_pass(args: argparse.Namespace):
from datetime import timedelta
from app.domain.base import utcnow
from app.workers.billing import BillingWorker
worker = BillingWorker()
async def one_pass() -> WorkerOutcome:
end = utcnow()
return await worker.roll_up(
period_start=end - timedelta(days=max(1, args.period_days)),
period_end=end,
)
return one_pass
def _ai_runtime_pass(args: argparse.Namespace):
from app.workers.ai_runtime import AiRuntimeWorker
worker = AiRuntimeWorker()
async def one_pass() -> WorkerOutcome:
return await worker.drain(limit=args.limit)
return one_pass
def _sandbox_gateway_pass(args: argparse.Namespace):
from app.workers.sandbox_gateway import SandboxGatewayWorker
gateway = SandboxGatewayWorker()
async def one_pass() -> WorkerOutcome:
return await gateway.reap(
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
)
return one_pass
def _pass_function(args: argparse.Namespace):
"""Return a zero-argument coroutine function for one pass."""
if args.worker == "billing":
return _billing_pass(args)
if args.worker == "ai-runtime":
return _ai_runtime_pass(args)
return _sandbox_gateway_pass(args)
async def _main_async(args: argparse.Namespace) -> int:
one_pass = _pass_function(args)
stop = asyncio.Event()
if args.loop:
install_signal_handlers(stop)
outcomes = await run_periodically(
one_pass,
interval_seconds=max(1.0, args.interval),
stop=stop,
run_once=not args.loop,
worker_name=args.worker,
)
failed = sum(o.failed for o in outcomes)
return 1 if failed else 0
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
args = _build_parser().parse_args(argv)
return asyncio.run(_main_async(args))
if __name__ == "__main__":
sys.exit(main())
+94
View File
@@ -0,0 +1,94 @@
"""AI runtime worker — drives queued tasks through the run engine.
The HTTP API can start a run inline (that is what the "اجرا" button does), but
a platform that only ever runs work on the request thread has two problems: a
slow model call holds a connection open, and a task created by anything other
than a button press never runs at all. This worker is the second driver.
It deliberately owns **no** orchestration logic. It finds runnable tasks and
hands each one to ``WorkforceService.start_run`` — the same call the router
makes. Model routing, usage accounting, the timeline and the artifact are all
the service's business.
"""
from __future__ import annotations
from app.application.container import build_workforce_service
from app.core.errors import AppError
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.domain.workforce.models import TaskStatus
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.workforce import TaskRepository
from app.workers.base import WorkerOutcome, worker_session
class AiRuntimeWorker:
"""Drains the queue of tasks that nobody has run yet."""
name = "ai-runtime"
async def drain(self, *, limit: int = 10) -> WorkerOutcome:
"""Run up to ``limit`` queued tasks, oldest first.
A task that fails is recorded and the pass continues: one poisoned task
must not block the queue behind it.
"""
outcome = WorkerOutcome(name=self.name)
async with worker_session() as session:
tasks = TaskRepository(session)
workforce = build_workforce_service(session)
for task in await tasks.list_runnable(limit=limit):
# Re-read through the service so the tenant check and the
# "already closed" guard both apply. The worker gets no
# privileged bypass.
try:
current = await workforce.get_task_or_404(
organization_id=task.organization_id, task_id=task.id
)
except AppError:
outcome.skipped += 1
outcome.note(f"task {task.id}: vanished before the run")
continue
if current.status not in (TaskStatus.NEW, TaskStatus.TRIAGED):
outcome.skipped += 1
continue
try:
run = await workforce.start_run(
organization_id=task.organization_id,
task_id=task.id,
user_id=task.created_by_user_id or "",
employee_id=task.assigned_employee_id,
)
except Exception as exc: # noqa: BLE001 - keep draining
outcome.failed += 1
outcome.note(f"task {task.id}: {type(exc).__name__}: {exc}")
# Persist with the tenant attached: a task that fails to run
# is a tenant-visible problem, and the console must be able
# to show that organization what happened.
await record_error(
kind="task_run_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.WORKER,
severity=ErrorSeverity.ERROR,
organization_id=task.organization_id,
user_id=task.created_by_user_id,
metadata={
"worker": self.name,
"task_id": task.id,
"employee_id": task.assigned_employee_id,
},
)
continue
outcome.processed += 1
outcome.note(f"task {task.id} -> run {run.id} ({run.status})")
return outcome
__all__ = ["AiRuntimeWorker"]
+166
View File
@@ -0,0 +1,166 @@
"""Shared plumbing for the standalone workers.
The workers are deliberately thin. Every one of them is a *driver* that calls
the same application services the HTTP API calls — the wiring comes from
``app.application.container``, so a worker and the API can never disagree about
which adapter backs a port. No business rule lives in this package; if a worker
and the API ever disagreed, the API would be right.
That is also what makes the ``services/*`` split cheap later: moving a worker
into its own process changes the process boundary, not the code.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import SessionFactory
logger = logging.getLogger("agy.worker")
@dataclass(slots=True)
class WorkerOutcome:
"""What one pass of a worker did.
Returned rather than only logged, so a test can assert on it and an
operator can see a number instead of grepping a log file.
"""
name: str
processed: int = 0
skipped: int = 0
failed: int = 0
notes: list[str] = field(default_factory=list)
#: Keep the note list bounded: a worker that processes 10k rows must not
#: accumulate 10k strings in memory just to print a summary.
MAX_NOTES = 50
def note(self, message: str) -> None:
if len(self.notes) < self.MAX_NOTES:
self.notes.append(message)
@property
def ok(self) -> bool:
return self.failed == 0
@property
def touched(self) -> int:
return self.processed + self.skipped + self.failed
def summary(self) -> str:
return (
f"{self.name}: processed={self.processed} "
f"skipped={self.skipped} failed={self.failed}"
)
@asynccontextmanager
async def worker_session() -> AsyncIterator[AsyncSession]:
"""One committed transaction per pass; rolled back whole on failure.
A worker pass is all-or-nothing on purpose: a half-applied invoice rollup
is worse than no rollup, because the next pass would skip the period it
thinks it already billed.
"""
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def run_periodically(
pass_fn: Callable[[], Awaitable[WorkerOutcome]],
*,
interval_seconds: float,
stop: asyncio.Event,
run_once: bool = False,
worker_name: str | None = None,
) -> list[WorkerOutcome]:
"""Drive ``pass_fn`` until ``stop`` is set — or exactly once.
``run_once=True`` is the cron/scheduled shape (what the CLI uses by
default). The loop shape exists for long-running deployments.
A failing pass is persisted before the loop continues. A worker that only
logged to stderr would lose every failure the moment the container was
replaced — which is precisely when someone wants to know what went wrong.
"""
outcomes: list[WorkerOutcome] = []
name: str = worker_name or str(getattr(pass_fn, "__name__", "worker"))
while True:
try:
outcome = await pass_fn()
outcomes.append(outcome)
logger.info(outcome.summary())
for note in outcome.notes:
logger.info("%s | %s", outcome.name, note)
except Exception as exc: # noqa: BLE001 - a worker must survive a bad pass
logger.exception("%s: pass failed", name)
await _record_pass_failure(name, exc)
outcomes.append(
WorkerOutcome(name=name, failed=1, notes=[f"pass failed: {type(exc).__name__}"])
)
if run_once:
return outcomes
try:
# Returns early (cleanly) when ``stop`` is set during the wait.
await asyncio.wait_for(stop.wait(), timeout=interval_seconds)
return outcomes
except TimeoutError:
continue
async def _record_pass_failure(worker_name: str, exc: BaseException) -> None:
"""Persist a failed pass. Imported lazily to keep this module import-light."""
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
await record_error(
kind="worker_pass_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.WORKER,
severity=ErrorSeverity.ERROR,
metadata={"worker": worker_name},
)
def install_signal_handlers(stop: asyncio.Event) -> None:
"""Ask the event loop to set ``stop`` on SIGINT/SIGTERM.
Best-effort: on a platform without these signals (or outside the main
thread) the worker simply runs until interrupted some other way.
"""
loop = asyncio.get_running_loop()
for signal_name in ("SIGINT", "SIGTERM"):
try:
import signal as _signal
sig = getattr(_signal, signal_name)
except (AttributeError, ValueError):
continue
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError, ValueError):
continue
__all__ = [
"WorkerOutcome",
"install_signal_handlers",
"run_periodically",
"worker_session",
]
+133
View File
@@ -0,0 +1,133 @@
"""Billing worker — rolls the usage ledger into draft invoices.
The usage ledger is the source of truth for billing; an invoice is a *snapshot*
of one closed period. That ordering matters: the worker never invents an amount
the ledger does not already contain, and it never talks to a payment provider —
moving money is the API's job (``BillingService.settle_payment``), not a
background job's.
The one hard requirement is **idempotency**. A cron job that runs twice for the
same period must not bill twice, so a period that already has an invoice is
skipped rather than re-created.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.domain.base import utcnow
from app.domain.billing.models import Invoice, InvoiceStatus
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import InvoiceRepository
from app.infrastructure.repositories.identity import OrganizationRepository
from app.workers.base import WorkerOutcome, worker_session
#: A period shorter than this is almost certainly a bug (a stray manual run),
#: not a real billing cycle. Refusing it keeps the invoice table meaningful.
MIN_PERIOD = timedelta(hours=1)
class BillingWorker:
"""Turns usage into invoices, once per period, per organization."""
name = "billing-worker"
async def roll_up(
self,
*,
period_start: datetime | None = None,
period_end: datetime | None = None,
organization_id: str | None = None,
) -> WorkerOutcome:
"""Create a DRAFT invoice for every organization with usage in the period.
``organization_id`` narrows the pass to one tenant (useful for a
backfill or a support-driven re-run).
"""
end = period_end or utcnow()
start = period_start or (end - timedelta(days=30))
if end <= start:
raise ValueError("period_end must be after period_start")
if end - start < MIN_PERIOD:
raise ValueError(
f"billing period must be at least {MIN_PERIOD} long"
)
outcome = WorkerOutcome(name=self.name)
async with worker_session() as session:
invoices = InvoiceRepository(session)
ledger = UsageLedgerRepository(session)
organizations = OrganizationRepository(session)
if organization_id is not None:
targets = [organization_id]
else:
targets = [org.id for org in await organizations.list_all()]
for org_id in targets:
existing = await invoices.list_for_org(org_id)
if any(self._covers(i, start, end) for i in existing):
outcome.skipped += 1
continue
breakdown = await ledger.breakdown(
organization_id=org_id, since=start, until=end
)
# Six decimals, not two: a single small model call costs a
# fraction of a cent, and rounding to cents here would silently
# turn real usage into a zero invoice.
amount = round(breakdown.total_price, 6)
if amount <= 0:
# No billable usage: do not litter the table with $0 drafts.
outcome.skipped += 1
outcome.note(f"{org_id}: no billable usage in this period")
continue
invoice = await invoices.create(
Invoice(
organization_id=org_id,
amount=amount,
status=InvoiceStatus.DRAFT,
period_start=start,
period_end=end,
)
)
outcome.processed += 1
outcome.note(f"{org_id}: {invoice.id} amount={amount}")
return outcome
@staticmethod
def _naive_utc(value: datetime) -> datetime:
"""Normalise a timestamp to naive UTC for comparison.
SQLite drops ``tzinfo`` on round-trip while Postgres keeps it, so
comparing a freshly computed aware timestamp against one read back from
the database raises ``TypeError`` on one backend and works on the other.
Comparing in a single explicit frame avoids depending on which database
the deployment happens to use.
"""
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
@classmethod
def _covers(cls, invoice: Invoice, start: datetime, end: datetime) -> bool:
"""Is this period already invoiced?
Any invoice whose period *overlaps* the requested one counts. Overlap
rather than exact equality, because two runs a few seconds apart would
otherwise produce two near-identical invoices for the same window.
"""
if invoice.period_start is None or invoice.period_end is None:
return False
window_start, window_end = cls._naive_utc(start), cls._naive_utc(end)
return (
cls._naive_utc(invoice.period_start) < window_end
and window_start < cls._naive_utc(invoice.period_end)
)
__all__ = ["MIN_PERIOD", "BillingWorker"]
+94
View File
@@ -0,0 +1,94 @@
"""Sandbox gateway worker — housekeeping for the only sanctioned execution path.
Two jobs, both about not leaking resources:
1. **Reap idle sessions.** A sandbox session that nobody has touched for hours
is still consuming CPU and disk on the provider. The reaper stops it and
writes an audit event, because "the platform killed my sandbox" must be
answerable from the audit log.
2. **Report the policy in force.** The gateway is the one place where untrusted
code runs, so the worker logs the effective network policy and the absolute
caps at startup. An operator should be able to see the safety envelope
without reading the source.
The worker never executes a command. It only stops sessions — the command path
is ``SandboxService.run_command``, reachable from the API under a tenant check.
"""
from __future__ import annotations
from datetime import timedelta
from app.application.container import build_sandbox_service
from app.core.config import settings
from app.domain.audit.models import AuditAction, AuditEvent
from app.domain.base import utcnow
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.sandbox import WorkspaceRepository
from app.workers.base import WorkerOutcome, worker_session
#: Sessions are considered abandoned after this long with no activity.
DEFAULT_IDLE_MINUTES = 120
class SandboxGatewayWorker:
"""Stops idle sandbox sessions and records why."""
name = "sandbox-gateway"
async def reap(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
outcome = WorkerOutcome(name=self.name)
idle_before = utcnow() - timedelta(minutes=max(1, idle_minutes))
async with worker_session() as session:
sandbox = build_sandbox_service(session)
workspaces = WorkspaceRepository(session)
audit = AuditRepository(session)
stopped = await sandbox.reap_idle_sessions(
idle_before=idle_before, limit=limit
)
for sandbox_session in stopped:
# Resolve the tenant for the audit trail. A session whose
# workspace has vanished is still stopped, just recorded
# without an organization.
workspace = await workspaces.get(sandbox_session.workspace_id)
await audit.record(
event=AuditEvent(
action=AuditAction.SANDBOX_REAP,
organization_id=workspace.organization_id if workspace else None,
target_type="sandbox_session",
target_id=sandbox_session.id,
metadata_json={
"workspace_id": sandbox_session.workspace_id,
"idle_minutes": idle_minutes,
"reason": "idle_timeout",
},
)
)
outcome.processed += 1
outcome.note(f"stopped session {sandbox_session.id}")
if not stopped:
outcome.skipped += 1
return outcome
def policy_report(self) -> WorkerOutcome:
"""Describe the safety envelope this gateway is running under."""
outcome = WorkerOutcome(name=self.name)
outcome.note(f"sandbox provider = {settings.sandbox_provider}")
outcome.note(
f"command timeout = {settings.sandbox_command_timeout_seconds}s "
f"(absolute cap enforced in the service layer)"
)
outcome.note(f"max output = {settings.sandbox_max_output_bytes} bytes")
outcome.note("egress policy default = deny_all (set per resource limit)")
return outcome
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]
+20
View File
@@ -0,0 +1,20 @@
# Local-only constraints for Intel (x86_64) macOS development machines.
#
# Why this file exists
# --------------------
# Newer releases of these two packages publish *arm64-only* macOS wheels. On an
# Intel Mac pip therefore falls back to the source distribution and tries to
# compile a Rust/C extension, which takes many minutes and needs a toolchain.
# These pins select the newest releases that still ship an x86_64 (or universal2)
# macOS wheel, so `pip install` stays binary and fast.
#
# This is a *developer convenience* constraint. It does NOT change any runtime
# behaviour, and it must NOT be used for container/production images, which
# should build from `pyproject.toml` alone on linux/amd64.
#
# Verified against PyPI:
# cryptography 46.0.3 -> cp38-abi3-macosx_10_9_universal2.whl
# argon2-cffi-bindings 21.2.0 -> cp36-abi3-macosx_10_9_x86_64.whl
#
cryptography<47
argon2-cffi-bindings<22
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@agy/api",
"version": "0.1.0",
"private": true,
"description": "AGY Web backend — FastAPI + SQLAlchemy, Clean/Hexagonal architecture.",
"scripts": {
"venv": "python3 -m venv .venv && .venv/bin/python -m pip install --upgrade pip && .venv/bin/python -m pip install -e '.[dev]'",
"dev": ".venv/bin/python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000",
"start": ".venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000",
"lint": ".venv/bin/python -m ruff check app tests",
"typecheck": ".venv/bin/python -m mypy app",
"test": ".venv/bin/python -m pytest -q",
"format": ".venv/bin/python -m ruff format app tests && .venv/bin/python -m ruff check --fix app tests",
"db:migrate": ".venv/bin/python -m alembic upgrade head",
"db:revision": ".venv/bin/python -m alembic revision --autogenerate",
"db:seed": ".venv/bin/python -m app.seed"
}
}
+65
View File
@@ -0,0 +1,65 @@
[project]
name = "agy-web-api"
version = "0.1.0"
description = "AGY Web backend — FastAPI + SQLAlchemy, Clean/Hexagonal architecture."
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"sqlalchemy[asyncio]>=2.0.36",
"psycopg[binary]>=3.2.3",
"aiosqlite>=0.20.0",
"alembic>=1.14.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"email-validator>=2.2.0",
"PyJWT>=2.10.0",
"argon2-cffi>=23.1.0",
"cryptography>=44.0.0",
"redis>=5.2.0",
"httpx>=0.28.0",
"python-multipart>=0.0.20",
]
[project.optional-dependencies]
# Remote sandbox providers. Kept optional: these SDKs are large and move fast,
# and a default install must run with SANDBOX_PROVIDER=mock and zero credentials.
# Enable with: pip install -e ".[sandbox-remote]"
sandbox-remote = [
"daytona>=0.100",
"e2b>=1.0",
]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.25.0",
"ruff>=0.9.0",
"mypy>=1.14.0",
]
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.ruff]
line-length = 100
target-version = "py312"
# Alembic's autogenerated revisions are machine-written: their long lines and
# import style are not ours to fix, and editing them by hand invites mistakes.
exclude = ["alembic/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
ignore = ["B008"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = ["ignore::DeprecationWarning"]
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
warn_unused_ignores = false
View File
+248
View File
@@ -0,0 +1,248 @@
"""Shared test fixtures.
Tests run against an in-memory SQLite database and the ``mock`` adapters, so the
suite needs no Postgres, no Redis and no credentials.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Importing the ORM package registers every table on ``Base.metadata``. Bound to
# a private name on purpose: ``app`` is also the FastAPI instance imported below,
# and binding the package to the same name would shadow it.
import app.infrastructure.db.models as _orm_models # noqa: F401
from app.core.config import settings
from app.core.database import Base, get_session
from app.domain.base import new_id
from app.infrastructure.db.models import UserRow
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def _production_shaped_jwt_secret():
"""Sign test tokens with a 32+ byte key.
The development default is ``change-me`` (9 bytes), so PyJWT emits an
``InsecureKeyLengthWarning`` for every token signed or verified — hundreds of
lines per run, which buries genuine warnings. Production refuses a short
secret outright; that guard is covered by ``tests/test_config_guard.py``.
"""
settings.jwt_secret = "test-only-jwt-secret-with-more-than-32-bytes"
@pytest_asyncio.fixture
async def engine():
# StaticPool keeps one connection alive so the in-memory schema survives
# across the sessions a single request opens.
test_engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield test_engine
await test_engine.dispose()
@pytest_asyncio.fixture
async def session_factory(engine):
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest_asyncio.fixture
async def redirect_error_log(engine, monkeypatch):
"""Point the error recorder and the workers at the in-memory database.
Neither goes through the ``get_session`` dependency override that ``client``
installs — the recorder deliberately opens its own session, and the workers
are not HTTP requests at all. Without this redirect, any test that triggers a
failure path would write into the developer's real ``agy_web.db``.
Not autouse: only the modules that exercise failure paths need it, and the
``engine`` it depends on builds the whole schema.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
import app.infrastructure.error_log as error_log
from app.workers import base as worker_base
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
monkeypatch.setattr(error_log, "SessionFactory", factory)
monkeypatch.setattr(worker_base, "SessionFactory", factory)
return factory
@pytest_asyncio.fixture
async def client(engine, session_factory):
async def _override_session():
async with session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_session] = _override_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client:
yield test_client
app.dependency_overrides.clear()
async def signup(
client: AsyncClient, email: str, password: str = "password123", name: str = ""
) -> dict:
response = await client.post(
"/api/auth/signup", json={"email": email, "password": password, "name": name}
)
assert response.status_code == 201, response.text
return response.json()
def auth_header(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest_asyncio.fixture
async def platform_owner_token(client: AsyncClient, session_factory) -> str:
"""A signed-up user promoted to platform owner.
The promotion is done directly in the database because there is deliberately
no API that can grant platform-owner status.
"""
# ``example.com`` is a documentation domain and passes email validation.
# Do NOT use ``.local``/``.test``: those are special-use TLDs and newer
# ``email-validator`` releases reject them, which fails the fixture.
email = "platform-owner@example.com"
body = await signup(client, email, name="مالک سامانه")
async with session_factory() as session:
row = (
await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one()
row.is_platform_owner = True
await session.commit()
return body["access_token"]
#: The system agent templates ``app/seed.py`` installs. Mirrored here so the test
#: suite exercises the same employee-creation path a seeded install uses.
SYSTEM_TEMPLATES: list[tuple[str, str, str, list[str]]] = [
("مدیر سازمان", "manager", "کارها را تقسیم می‌کند.", ["knowledge.search"]),
(
"برنامه‌نویس",
"developer",
"کد می‌نویسد و تست می‌کند.",
["sandbox.run_command", "sandbox.write_file"],
),
]
#: The routing policies ``app/seed.py`` installs.
SYSTEM_ROUTING_POLICIES: list[tuple[str, str, bool, str]] = [
("سریع و ارزان", "cheap", True, "برای کارهای روزمره."),
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
]
#: The mock models ``app/seed.py`` installs: (internal_name, display, capabilities).
SYSTEM_MODELS: list[tuple[str, str, list[str]]] = [
("mock-fast", "Mock Fast", ["text"]),
("mock-reasoning", "Mock Reasoning", ["text", "reasoning", "code"]),
]
#: The rate cards ``app/seed.py`` installs: (unit, customer_price, internal_cost).
SYSTEM_RATE_CARDS: list[tuple[str, float, float]] = [
("model_input_tokens", 0.0000004, 0.0000002),
("model_output_tokens", 0.0000016, 0.0000008),
("tool_call", 0.0005, 0.0001),
("premium_employee_run", 0.02, 0.008),
]
#: The sandbox templates ``app/seed.py`` installs: (name, image, description).
SYSTEM_SANDBOX_TEMPLATES: list[tuple[str, str, str]] = [
("python-3.13", "python:3.13-slim", "محیط پایتون برای اجرای تست و اسکریپت."),
("node-22", "node:22-slim", "محیط Node برای build و اجرای پروژه‌های فرانت‌اند."),
]
@pytest_asyncio.fixture
async def seeded(session_factory):
"""Install the platform-level seed rows a fresh install would have.
Agent templates, routing policies, the mock provider/models, the rate cards
and the sandbox templates are all *platform* data (no organization owns
them), so without this an employee cannot be created, a run cannot resolve a
model to route to, usage cannot be priced and no sandbox can be started —
none of which the real seeded install lacks.
"""
from app.infrastructure.db.models import (
AgentTemplateRow,
AiModelRow,
AiProviderRow,
RateCardRow,
RoutingPolicyRow,
SandboxTemplateRow,
)
async with session_factory() as session:
for name, role_key, description, tools in SYSTEM_TEMPLATES:
session.add(
AgentTemplateRow(
name=name,
role_key=role_key,
description=description,
default_prompt=f"تو {name} هستی. وظیفه‌ات: {description}",
default_tools_json=tools,
is_system=True,
)
)
for name, mode, is_default, description in SYSTEM_ROUTING_POLICIES:
session.add(
RoutingPolicyRow(
name=name, description=description, mode=mode,
is_default=is_default, is_active=True,
)
)
# A run cannot reach a model without an active provider + model, and
# ``pick_for_mode`` returns ``None`` when the catalogue is empty — which
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
provider_id = new_id()
session.add(
AiProviderRow(id=provider_id, name="mock", type="mock", base_url="", is_active=True)
)
for internal, display, capabilities in SYSTEM_MODELS:
session.add(
AiModelRow(
provider_id=provider_id,
internal_name=internal,
display_name_for_admin=display,
capabilities=capabilities,
input_price_per_1m=0.3,
output_price_per_1m=1.2,
is_active=True,
)
)
for unit, customer_price, internal_cost in SYSTEM_RATE_CARDS:
session.add(
RateCardRow(
name=unit.replace("_", " "), unit=unit,
customer_price=customer_price,
internal_cost_estimate=internal_cost, is_active=True,
)
)
for name, image, description in SYSTEM_SANDBOX_TEMPLATES:
session.add(
SandboxTemplateRow(
name=name, provider="mock", image=image, description=description,
default_cpu=1.0, default_memory_mb=1024, default_disk_mb=2048,
is_system=True,
)
)
await session.commit()
return True

Some files were not shown because too many files have changed in this diff Show More