"""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 the agy (Google Antigravity) provider with its model catalog. agy is the only model provider the platform talks to; accounts are added from the admin console. 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" #: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id #: the Antigravity backend expects; the display name is admin-only. The 4th #: element is the reasoning effort (empty = provider default) — the console can #: change it per model, and the adapter only sends it to thinking-capable #: models. Seeded for every deployment and reconciled on *every* run, so a #: database created by an older version converges without being rebuilt. AGY_MODELS: list[tuple[str, str, list[Capability], str]] = [ ( "gemini-3-flash", "Gemini 3 Flash", [Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT], "", ), ( "gemini-3-pro-high", "Gemini 3 Pro", [ Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.VISION, Capability.LONG_CONTEXT, ], "high", ), ( "claude-sonnet-4-5", "Claude Sonnet 4.5", [Capability.TEXT, Capability.CODE, Capability.TOOLS], "", ), ( "claude-sonnet-4-5-thinking", "Claude Sonnet 4.5 (Thinking)", [Capability.TEXT, Capability.CODE, Capability.REASONING], "high", ), ( "claude-opus-4-5-thinking", "Claude Opus 4.5 (Thinking)", [Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT], "high", ), ("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE], ""), ] 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` 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]) # The agy catalog (Google Antigravity). agy is the only model provider, # so the provider row and its models are created on a first run and # reconciled on every later one: display names, capabilities, efforts # and activation follow this table, and a model removed here stays in # the database (neither table nor row deletion is safe for a seed). agy_provider_row = ( await session.execute( select(AiProviderRow).where(AiProviderRow.name == "agy") ) ).scalars().first() if agy_provider_row is None: agy_provider = Provider(name="agy", type=ProviderType.AGY, base_url="") agy_provider_row = AiProviderRow( id=agy_provider.id, name=agy_provider.name, type=agy_provider.type.value, base_url="", is_active=True, ) session.add(agy_provider_row) await session.flush() for internal, display, caps, effort in AGY_MODELS: existing = ( await session.execute( select(AiModelRow).where( AiModelRow.provider_id == agy_provider_row.id, AiModelRow.internal_name == internal, ) ) ).scalars().first() if existing is not None: # Reconcile: an older database has these rows with different # metadata (efforts did not exist, capabilities moved). existing.display_name_for_admin = display existing.capabilities = [str(c) for c in caps] existing.reasoning_effort = effort existing.is_active = True continue model = AiModel( provider_id=agy_provider_row.id, internal_name=internal, display_name_for_admin=display, capabilities=[str(c) for c in caps], # agy is billed through the Google AI subscription, so the # internal token cost estimate is zero; the operator sets the # customer-facing price on the rate card. input_price_per_1m=0.0, output_price_per_1m=0.0, reasoning_effort=effort, ) 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, reasoning_effort=model.reasoning_effort, is_active=True, ) ) logger.info( "agy is the only model provider; catalog active " "(accounts are added from the admin console)" ) 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