"""Idempotent development seed. Run with ``pnpm db:seed``. Creates the platform owner, a demo *manager* with a sample organization that manager owns, 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. Platform hierarchy this seed models (there is deliberately no public signup): - exactly one ``owner`` — the account that owns the whole system and is the only one allowed into the admin console; - the demo account is a ``manager`` — the tier that may create organizations and join other managers' organizations by invitation; - the platform owner is *not* auto-joined to any organization: the product rule is that the owner observes tenants from the console, rather than silently sitting inside their data. """ from __future__ import annotations import asyncio import logging from sqlalchemy import select, update 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, PlatformRole, 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, ) from app.infrastructure.repositories.identity import UserRepository 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_role: PlatformRole ) -> User: """Get or create one seeded account, converging its platform role. The reconciliation matters for installs seeded *before* the single-owner model: the migration already mapped the old boolean onto ``platform_role``, and this pass fixes everything the migration could not know (for example which of several legacy owners the seed intended). Exactly one row may hold ``owner`` — the partial unique index enforces it — so promoting this account first demotes any other owner to ``manager``. """ existing = ( await session.execute(select(UserRow).where(UserRow.email == email)) ).scalar_one_or_none() if existing is not None: if existing.platform_role != platform_role.value: if platform_role == PlatformRole.OWNER: # Demote the current seat holder before taking it, so the # unique index never sees two owners. await session.execute( update(UserRow) .where( UserRow.platform_role == PlatformRole.OWNER.value, UserRow.id != existing.id, ) .values(platform_role=PlatformRole.MANAGER.value) ) existing.platform_role = platform_role.value await session.flush() return User( id=existing.id, email=existing.email, password_hash=existing.password_hash, name=existing.name, platform_role=PlatformRole(existing.platform_role), ) user = await UserRepository(session).create( User( email=email, password_hash=hash_password(password), name=name, platform_role=platform_role, ) ) logger.info("created user %s (%s)", email, platform_role.value) return user async def _ensure_org(session, *, owner: User) -> Organization: """Get or create the sample organization, owned by ``owner``. Idempotent in all three parts: the organization, the owner's membership and the credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with exactly one organization and no duplicate rows. The platform owner is deliberately *not* a member — see the module docstring. Any membership a platform owner picked up in an older database (the seed used to join both accounts) is removed here so the model stays honest after a re-seed. """ 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=owner.id) ) logger.info("created organization %s", org.slug) elif org.owner_user_id != owner.id: await orgs.reassign_owner(org.id, owner.id) org = await orgs.get(org.id) assert org is not None # The org owner is always an active OWNER member. Without this the demo # account can log in but belongs to no organization, so every screen # renders empty and the account looks broken. membership = await memberships.get(org.id, owner.id) if membership is None: await memberships.add( Membership( organization_id=org.id, user_id=owner.id, role=Role.OWNER, status=MembershipStatus.ACTIVE, ) ) logger.info("added %s to %s as owner", owner.email, org.slug) elif membership.role != Role.OWNER or not membership.is_active: await memberships.set_role(membership.id, Role.OWNER) await memberships.set_status(membership.id, MembershipStatus.ACTIVE) # Platform owners observe tenants from the console; they do not sit inside # them. Remove any such membership left by an older seed. platform_owners = ( await session.execute( select(UserRow).where(UserRow.platform_role == PlatformRole.OWNER.value) ) ).scalars().all() for platform_owner in platform_owners: stale = await memberships.get(org.id, platform_owner.id) if stale is not None: await memberships.remove(stale.id) logger.info( "removed platform owner %s from %s (owner stays out of tenants)", platform_owner.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: await _ensure_user( session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD, name="مالک سامانه", platform_role=PlatformRole.OWNER, ) # The demo account is a *manager*: the tier the owner creates that can # build its own organizations. It owns the sample organization, so # logging in with it lands on a populated dashboard rather than an # empty one. demo = await _ensure_user( session, email=DEMO_EMAIL, password=DEMO_PASSWORD, name="کاربر نمونه", platform_role=PlatformRole.MANAGER, ) await _ensure_org(session, 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