commit 717e53a7ce7e6e477e9c6a16eef7d6776b4db605 Author: mjalmoudy94 Date: Tue Oct 13 00:39:28 2026 +0330 نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..85a7841 --- /dev/null +++ b/.env.example @@ -0,0 +1,77 @@ +# --------------------------------------------------------------------------- +# AGY Web — sample environment file. +# Copy to `.env` and fill in real values. NEVER commit a real `.env`. +# Every external provider defaults to `mock` so the stack runs with zero +# credentials. Flip the *_PROVIDER vars once real credentials exist. +# --------------------------------------------------------------------------- + +APP_ENV=development +APP_URL=http://localhost:3000 +API_URL=http://localhost:8000 + +# Origins the browser is allowed to call the API from, comma-separated. +# `localhost` and `127.0.0.1` are DIFFERENT origins to a browser: if you start +# the dev server with `-H 127.0.0.1` and this list only has `localhost`, every +# request fails with an opaque "Disallowed CORS origin". Both are listed here. +# Production must set this explicitly — the dev default is rejected at boot. +CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# --- Data stores ----------------------------------------------------------- +DATABASE_URL=postgresql+psycopg://agy:agy@localhost:5432/agy_web +REDIS_URL=redis://localhost:6379/0 + +# --- Security -------------------------------------------------------------- +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +JWT_SECRET=change-me +# Must be exactly 32 bytes, base64/hex encoded. +ENCRYPTION_KEY=change-me-32-bytes + +# --- AI models (hidden from customers) ------------------------------------- +# mock | openai_compatible +AI_DEFAULT_PROVIDER=mock +OPENAI_COMPATIBLE_BASE_URL= +OPENAI_COMPATIBLE_API_KEY= + +# --- Git provider ---------------------------------------------------------- +# mock | gitea +GIT_PROVIDER=mock +GITEA_URL= +GITEA_INTERNAL_URL= +GITEA_ADMIN_TOKEN= + +# --- Preview domain routing ------------------------------------------------ +# mock | caddy +DOMAIN_ROUTER=mock +PREVIEW_ROOT_DOMAIN=msa.artacloud.ir +PREVIEW_PORT_START=8080 +PREVIEW_PORT_END=8999 +CADDYFILE_PATH=/etc/caddy/Caddyfile + +# --- Sandbox --------------------------------------------------------------- +# mock | daytona | e2b +SANDBOX_PROVIDER=mock +DAYTONA_API_KEY= +DAYTONA_API_URL= +E2B_API_KEY= + +# --- Payments -------------------------------------------------------------- +# mock | stripe +PAYMENT_PROVIDER=mock +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# --- Error logging --------------------------------------------------------- +# Every failure the platform catches is written to the `error_events` table and +# is readable at /admin/errors (platform owner only). Errors are redacted before +# they are stored — passwords, tokens, API keys, JWTs and DSN credentials are +# stripped by app/core/redact.py. +# +# ERROR_LOG_ENABLED=0 turns persistence off entirely; failures still reach +# stderr, but nothing is written to the database. +ERROR_LOG_ENABLED=1 + +# Whether 4xx responses are recorded too. Off by default: a public API receives +# a constant stream of 404s from scanners, and recording them all buries the +# genuine bugs. Turn it on while debugging a misbehaving client. +# 5xx responses are always recorded, whatever this is set to. +ERROR_LOG_CLIENT_ERRORS=0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ec7afe --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# dependencies +node_modules/ +.pnpm-store/ + +# builds +.next/ +out/ +dist/ +build/ +*.tsbuildinfo + +# python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# local databases (runtime data, not source) +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# env & secrets +.env +.env.local +.env.*.local +!.env.example + +# logs +*.log +logs/ + +# editor / os +.DS_Store +.idea/ +.vscode/ +*.swp + +# coverage +coverage/ +htmlcov/ +.coverage + +# agent working memory — session state, not project source +.workbuddy-ai/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..34cfe74 --- /dev/null +++ b/.npmrc @@ -0,0 +1,10 @@ +# pnpm's default "isolated" node_modules is a symlink farm into +# node_modules/.pnpm. The sandboxed filesystem broker used in this environment +# refuses those symlink operations (ERR_PNPM_CODEBUDDY_BROKER_DENY), which aborts +# the install part-way through linking. +# +# A hoisted linker produces a flat node_modules without the symlink farm, which +# installs cleanly here. It is a development-environment accommodation only: it +# changes how dependencies are laid out on disk, not which versions are +# resolved, so the lockfile and the built artifacts are unaffected. +node-linker=hoisted diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c027be3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1194 @@ +# پلن نسخه وب مستقل AGY Bot + +تاریخ تدوین: 2026-10-12 +محل توسعه: `/Users/mamad/Projects/agy-bot/web` +وضعیت: فاز برنامه‌ریزی محصول، معماری و مسیر توسعه + +--- + +## 1. خلاصه تصمیم اصلی + +نسخه جدید نباید بازنویسی خط‌به‌خط ربات تلگرام فعلی باشد. پیشنهاد اصلی این است که محصول جدید به عنوان یک پلتفرم SaaS مستقل برای «نیروی کار هوش مصنوعی سازمانی» ساخته شود؛ یعنی کاربر نهایی مدل‌ها، کلیدها، Providerها و پیچیدگی‌های orchestration را نمی‌بیند، بلکه فقط با سازمان، مدیر هوش مصنوعی، کارمندان هوش مصنوعی، فایل‌ها، پروژه‌ها، مصرف و پرداخت سروکار دارد. + +تعریف پیشنهادی محصول: + +> یک وب اپلیکیشن چندسازمانی که مالک سامانه در آن مدل‌ها، Providerها، سیاست‌های هوش مصنوعی، تعرفه‌ها، ابزارها و محیط‌های اجرای ایزوله را مدیریت می‌کند؛ کاربران سازمان می‌سازند، کارمندهای هوش مصنوعی استخدام/فعال می‌کنند، کارها را به مدیر یا کارمندها می‌سپارند، مصرفشان اندازه‌گیری می‌شود و بر اساس اعتبار یا اشتراک پرداخت انجام می‌دهند. + +--- + +## 2. هدف نسخه جدید + +### 2.1 هدف‌های محصولی + +1. خروج کامل از بستر تلگرام و تبدیل به یک وب اپلیکیشن حرفه‌ای. +2. حذف وابستگی به `chat_id`، callbackهای تلگرام، message handlerها و محدودیت‌های UI تلگرام. +3. ساخت تجربه کاربری شبیه داشبوردهای مدرن SaaS. +4. پنهان‌سازی کامل مدل‌ها و Providerها از دید مشتری. +5. تعریف «سازمان»، «عضو»، «نقش»، «کارمند هوش مصنوعی»، «مدیر سازمان»، «پروژه»، «محیط اجرا»، «مصرف» و «پرداخت» به عنوان مفاهیم اصلی محصول. +6. ارائه محیط ایزوله برای اجرای کد/اپلیکیشن هر کاربر یا سازمان. +7. ایجاد زیرساخت billing، quota، audit، observability و کنترل کیفیت از روز اول. + +### 2.2 هدف‌های فنی + +1. معماری مستقل از کد فعلی؛ فقط از تجربه و منطق دامنه فعلی الهام گرفته شود. +2. طراحی بر اساس Clean Architecture، Hexagonal Architecture و Domain-Driven Design. +3. کنترل کامل multi-tenancy و امنیت داده‌ها. +4. قابلیت scale افقی برای runهای هوش مصنوعی و sandboxها. +5. امکان تغییر Provider و مدل بدون تغییر تجربه مشتری. +6. ثبت کامل eventها برای billing، audit، replay و debug. + +--- + +## 3. یافته‌های بررسی بازار و نمونه‌های مشابه + +این بخش بر اساس جستجوی وب و بررسی منابع رسمی/نزدیک به رسمی تدوین شده است. + +### 3.1 Dify + +منبع: `https://docs.dify.ai/` و صفحات Model Providers / Agent + +ویژگی‌های مهم: + +- workspace دارد و Providerهای مدل در سطح workspace مدیریت می‌شوند. +- فقط owner/admin می‌تواند Provider و API key را مدیریت کند. +- appها از مدل‌های متصل‌شده در workspace استفاده می‌کنند. +- Agent app دارد؛ Agent می‌تواند تصمیم بگیرد از tool استفاده کند یا از knowledge base پاسخ بدهد. +- امکان debug با چند مدل، tool calling، RAG، multimodal input و انتشار app وجود دارد. + +درس برای محصول ما: + +- مدل‌ها باید در یک «AI Control Plane» مرکزی تعریف شوند، نه توسط کاربر نهایی. +- مشتری باید Agent/Employee ببیند، نه مدل خام. +- برای هر Agent باید prompt، ابزار، knowledge و محدودیت‌های اجرای آن قابل نسخه‌بندی باشد. + +### 3.2 LangGraph / LangSmith Deployment + +منبع: `https://docs.langchain.com/langgraph-platform/` و صفحات LangSmith Deployment + +ویژگی‌های مهم: + +- مفاهیم production-grade مثل assistants، threads و runs دارد. +- روی durable execution، streaming، horizontal scaling، deployment و observability تمرکز دارد. +- حالت‌های cloud، hybrid، self-hosted و standalone agent server دارد. + +درس برای محصول ما: + +- اجرای Agent باید به صورت «Run» مستقل، قابل پیگیری، قابل توقف، قابل resume و قابل replay طراحی شود. +- thread/conversation نباید فقط یک لیست پیام ساده باشد؛ باید state، artifact، tool call، sandbox session و billing events را هم داشته باشد. + +### 3.3 CrewAI Enterprise + +منبع: `https://crewai.com/agent-management-platform` + +ویژگی‌های مهم: + +- build، deploy، observe و govern برای Agentها را به صورت یک control plane ارائه می‌کند. +- روی multi-agent workflows، tracing، human-in-the-loop، audit trail، RBAC و governance تأکید دارد. +- برای سازمان‌هایی طراحی شده که Agentهای زیادی در مقیاس production دارند. + +درس برای محصول ما: + +- فقط chat کافی نیست؛ باید management، governance و visibility داشته باشیم. +- مدیر سازمان باید بفهمد کدام Agent چه کاری انجام داده، چقدر هزینه داشته و کجا گیر کرده است. + +### 3.4 Dust + +منبع: `https://dust.tt/` + +ویژگی‌های مهم: + +- platform برای همکاری انسان و Agentهاست. +- اتصال به knowledge و ابزارهای سازمانی مثل Slack، Notion، Google Drive، GitHub، Salesforce و Zendesk دارد. +- multi-model است و مدل مناسب برای هر workflow قابل انتخاب است. +- permissions، audit logs، analytics، usage، costs و ROI دارد. + +درس برای محصول ما: + +- باید data permission و agent permission جدا باشند: اینکه Agent به چه داده‌ای دسترسی دارد، با اینکه چه کسی می‌تواند از Agent استفاده کند فرق دارد. +- analytics و cost visibility باید از ابتدا بخشی از محصول باشد. + +### 3.5 Zapier Agents + +منبع: `https://zapier.com/agents` + +ویژگی‌های مهم: + +- Agentها به هزاران اپلیکیشن متصل می‌شوند. +- Agent templates دارد. +- کاربر می‌تواند Agent بسازد، مانیتور کند، با آن chat کند و آن را در automationها به کار بگیرد. + +درس برای محصول ما: + +- marketplace/template برای کارمندهای آماده بسیار مهم است. +- اتصال به ابزارهای بیرونی باید با connector abstraction ساخته شود. + +### 3.6 Relevance AI و Lindy + +منابع جستجوشده: صفحات محصول و نتایج معرفی AI Workforce / AI Employees + +ویژگی‌های قابل توجه: + +- تمرکز روی AI workforce و AI employee. +- Agentهای آماده برای فروش، پشتیبانی، تحقیق، عملیات و automation. +- no-code/low-code builder برای تیم‌های غیرتوسعه‌دهنده. + +درس برای محصول ما: + +- زبان محصول باید از «مدل» و «workflow خام» به سمت «کارمند»، «مدیر»، «وظیفه»، «تیم» و «نتیجه کار» برود. +- تجربه کاربر باید شبیه استخدام و مدیریت نیروی کار هوش مصنوعی باشد. + +### 3.7 E2B + +منبع: `https://docs.e2b.dev/` + +ویژگی‌های مهم: + +- sandbox ایزوله برای اجرای کد، پردازش داده و ابزارها. +- sandbox سریع و امن، قابل pause/resume. +- template برای تعریف محیط شروع. +- persistence با ذخیره filesystem و memory هنگام pause. + +درس برای محصول ما: + +- اجرای کد Agent یا نرم‌افزار مشتری نباید روی host اصلی انجام شود. +- sandbox باید lifecycle مستقل، template و persistence داشته باشد. + +### 3.8 Daytona + +منبع: `https://www.daytona.io/docs` + +ویژگی‌های مهم: + +- sandboxهای کاملاً ایزوله با kernel، filesystem، network stack و منابع اختصاصی. +- اجرای Python، TypeScript و JavaScript. +- snapshot برای تداوم state. +- SDK، API و CLI برای lifecycle، filesystem و process execution. + +درس برای محصول ما: + +- برای MVP می‌توان به جای ساخت sandbox infra از صفر، یک لایه Sandbox Gateway روی Daytona/E2B ساخت. +- snapshot برای محیط‌های کاربری و پروژه‌های طولانی‌مدت ضروری است. + +### 3.9 Modal Sandboxes + +منبع جستجو: Modal Sandboxes docs + +ویژگی‌های مهم: + +- sandbox برای اجرای کد untrusted یا agent-generated. +- مناسب اجرای command روی repo، تست، jobهای موقت و workloadهای AI. + +درس برای محصول ما: + +- برای کارهای کوتاه‌مدت و compute موقت، sandboxهای serverless سریع گزینه خوبی هستند. + +### 3.10 Firecracker + +منبع: `https://firecracker-microvm.github.io/` + +ویژگی‌های مهم: + +- microVM برای سرویس‌های multi-tenant، function-based و container-based. +- VM-level isolation با سرعت و overhead نزدیک به container. +- KVM، jailer، rate limiter و attack surface کوچک. +- boot سریع و مناسب workloadهای multi-tenant. + +درس برای محصول ما: + +- اگر بخواهیم sandbox را خودمان در مقیاس production بسازیم، microVM جدی‌ترین مسیر امنیتی است. +- container تنها برای اجرای کد untrusted کافی نیست مگر اینکه با policy و isolation سخت‌گیرانه ترکیب شود. + +### 3.11 Stripe Billing + +منبع: `https://docs.stripe.com/billing` + +ویژگی‌های مهم: + +- subscription، invoice، pricing model، usage-based billing، tiered pricing و customer portal. +- مناسب ثبت مصرف، صدور invoice و مدیریت اشتراک. + +درس برای محصول ما: + +- billing باید event-driven باشد: هر model call، sandbox runtime، storage، tool call و خروجی قابل اندازه‌گیری باید usage event تولید کند. + +### 3.12 Supabase + +منبع: `https://supabase.com/docs` + +ویژگی‌های مهم: + +- Postgres، Auth، Storage، Realtime، Edge Functions، RLS، API، Cron و Queues. +- self-host و managed دارد. + +درس برای محصول ما: + +- اگر سرعت MVP مهم‌تر از کنترل کامل infra باشد، Supabase برای auth/storage/database سریع است. +- اما برای محصول چندسازمانی با billing و sandbox جدی، بهتر است schema و backend core تحت کنترل خودمان باشد. + +--- + +## 4. مشکلات فعلی که نسخه جدید باید حل کند + +بررسی کد فعلی نشان می‌دهد پروژه فعلی یک Telegram Bot بسیار بزرگ و پیچیده است. مهم‌ترین نقاط درد: + +### 4.1 وابستگی عمیق به تلگرام + +نمونه فایل‌ها: + +- `bot.py`: import مستقیم `telegram`، `Application`، `CommandHandler`، `MessageHandler`، `CallbackQueryHandler`، `InlineKeyboardMarkup` و منطق اصلی پیام/کلیک. +- `config.py`: تنظیمات `TELEGRAM_BOT_TOKEN`، `BOT_MODE`، webhook، polling و secret token. +- `web_panel.py`: اعتبارسنجی `Telegram WebApp initData` و استفاده از `X-Telegram-Init-Data`. +- `bot_menus.py` و `bot_org_models.py`: UI بر اساس inline keyboard و callback_data. + +نتیجه: + +- UI، احراز هویت، state machine و domain logic با Telegram coupling ساخته شده‌اند. +- تغییر به وب واقعی با patch کردن همین کد، پیچیدگی را بیشتر می‌کند. + +### 4.2 state و wizardهای وابسته به پیام + +نمونه‌ها: + +- `USER_INPUT_STATES` +- callbackهای کوتاه `callback_data` +- wizardهای چندمرحله‌ای داخل پیام‌ها +- tokenهای موقت داخل memory مثل `PANEL_TOKENS` + +راه‌حل نسخه جدید: + +- استفاده از session واقعی، فرم‌های وب، route state، database-backed workflow state و optimistic UI. +- wizardها باید entity مستقل در DB باشند، نه state موقت داخل process. + +### 4.3 هویت کاربر بر پایه chat_id + +نمونه‌ها: + +- `workspace_for(chat_id, project_name)` +- `owner_id` در بسیاری از جدول‌ها که عملاً با Telegram user id یکی شده است. + +راه‌حل نسخه جدید: + +- مدل هویتی جدید: `User`, `Organization`, `Membership`, `Role`, `Workspace`, `Project`. +- هیچ منطق جدیدی نباید به Telegram id وابسته باشد. + +### 4.4 SQLite و migrationهای runtime + +پروژه فعلی از SQLite مرکزی (`data/memory.db`) و تعداد زیادی migration داخل کد runtime استفاده می‌کند. + +راه‌حل نسخه جدید: + +- PostgreSQL به عنوان دیتابیس اصلی. +- migration رسمی با Alembic یا Prisma/Drizzle. +- multi-tenancy با `organization_id` و در لایه حساس با Row Level Security یا policy enforcement. + +### 4.5 monolith نامنظم و فایل‌های بسیار بزرگ + +نمونه‌ها: + +- `bot.py` چندین مسئولیت دارد: routing، UI، auth، org، engine، menus، background task، webhook. +- `web_panel.py` هم API، auth، template، org endpoints و integrationهای مختلف را در یک فایل ترکیب کرده است. + +راه‌حل نسخه جدید: + +- Modular Monolith تمیز در شروع؛ نه microservice زودهنگام. +- هر bounded context پوشه و interface خودش را داشته باشد. + +### 4.6 اجرای process و ابزار روی سرور اصلی + +نمونه‌ها: + +- `agy_engine.py` مدیریت process، timeout، kill group و registry دارد. +- `remote_dev_executor.py` تلاش کرده sandbox محدود بسازد اما همچنان سطح امنیتی آن با microVM برابر نیست. + +راه‌حل نسخه جدید: + +- اجرای هر کد untrusted فقط از مسیر Sandbox Gateway. +- host اصلی فقط control plane باشد، نه محل اجرای کد مشتری. + +--- + +## 5. پیشنهاد محصول برای نسخه وب + +### 5.1 نقش‌ها + +#### 5.1.1 مالک سامانه + +توانایی‌ها: + +- تعریف Providerهای AI و API keyها. +- تعریف مدل‌های مجاز و مدل‌های داخلی پنهان. +- تعریف routing policy: ارزان، سریع، دقیق، reasoning، multimodal. +- تعریف تعرفه‌ها و markup روی مصرف. +- تعریف sandbox templates. +- مشاهده کل مصرف، درآمد، خطاها، سازمان‌ها و runها. +- تعریف Agent/Employee templateهای پیش‌فرض. + +#### 5.1.2 مدیر سازمان مشتری + +توانایی‌ها: + +- ساخت سازمان. +- دعوت اعضا. +- خرید اعتبار یا اشتراک. +- ساخت پروژه. +- فعال‌سازی کارمندهای هوش مصنوعی. +- تعریف knowledge base سازمان. +- مشاهده مصرف و خروجی کارمندها. +- تنظیم approval policy. + +#### 5.1.3 عضو سازمان + +توانایی‌ها: + +- chat با مدیر یا کارمندهای مجاز. +- ساخت task. +- آپلود فایل. +- مشاهده artifactها و نتیجه کار. +- اجرای app یا preview در محیط ایزوله در صورت داشتن دسترسی. + +#### 5.1.4 کارمند هوش مصنوعی + +در UI مانند یک نیروی کاری نمایش داده می‌شود، اما در backend ترکیبی است از: + +- role/persona +- prompt version +- tool permissions +- model routing policy +- sandbox permissions +- knowledge scope +- budget cap +- approval requirements + +--- + +## 6. قابلیت‌های پیشنهادی برای افزودن + +### 6.1 داشبورد سازمانی + +- نمای سازمان‌ها و پروژه‌ها. +- وضعیت مصرف اعتبار. +- runهای فعال. +- خطاها و taskهای منتظر تأیید. +- خلاصه عملکرد کارمندها. + +### 6.2 AI Workforce Hub + +- لیست کارمندها: مدیر، برنامه‌نویس، تحلیل‌گر، بازبین، تستر، تولیدکننده محتوا، پشتیبان، محقق. +- templateهای آماده برای فعال‌سازی سریع. +- قابلیت ساخت کارمند سفارشی. +- سطح دسترسی هر کارمند به ابزارها، فایل‌ها و sandbox. + +### 6.3 Manager Chat + +- مدیر سازمان یک Agent ویژه است که کارها را triage می‌کند. +- کاربر با مدیر حرف می‌زند، نه با مدل. +- مدیر می‌تواند task بسازد، کارمند انتخاب کند، approval بگیرد و نتیجه را جمع‌بندی کند. + +### 6.4 Workflow و Task Board + +- taskها به صورت Kanban/List: New، Planning، Running، Waiting Approval، Done، Failed. +- هر task دارای run history، artifactها، cost، duration، logs و decisionهاست. +- هر run قابل replay/debug باشد. + +### 6.5 Knowledge Base سازمانی + +- upload فایل، URL، متن، repo، docs. +- tagging و scope. +- اتصال knowledge به Agentهای خاص. +- permission-aware retrieval. + +### 6.6 Sandbox Apps + +- هر سازمان/پروژه بتواند محیط اجرا داشته باشد. +- preview URL موقت برای app. +- اجرای command، تست، build، deploy preview. +- snapshot و restore. +- محدودیت CPU/RAM/disk/time/network. + +### 6.7 Billing و Usage + +- credit wallet. +- subscription plan. +- usage-based billing. +- نرخ جدا برای model calls، sandbox runtime، storage، connectors و premium employees. +- هشدار نزدیک شدن به سقف مصرف. +- invoice و payment history. + +### 6.8 Observability + +- trace هر Agent run. +- tool call timeline. +- token/cost breakdown. +- sandbox logs. +- error classification. +- quality metrics: success rate، retry rate، human intervention، average cost per task. + +### 6.9 Governance و امنیت + +- RBAC در سطح سازمان. +- approval برای عملیات خطرناک. +- audit log غیرقابل حذف. +- secret vault. +- data retention policy. +- خروجی‌های حساس با redaction. + +### 6.10 Marketplace داخلی + +- Agent templates. +- workflow templates. +- sandbox templates. +- connector templates. +- prompt packs. + +--- + +## 7. پیشنهاد محیط ایزوله برای هر کاربر/سازمان + +### 7.1 پیشنهاد نهایی + +بهترین مسیر پیشنهادی: + +1. برای MVP از یک abstraction به نام `Sandbox Gateway` استفاده شود. +2. پشت این Gateway در فاز اول از Daytona یا E2B استفاده شود، چون آماده، سریع، snapshot/persistence محور و مخصوص Agent/code execution هستند. +3. هم‌زمان interface داخلی طوری طراحی شود که بعداً بتوان backend sandbox را به Firecracker microVM یا Kubernetes self-hosted تغییر داد. +4. برای production جدی و کاهش هزینه در مقیاس، مسیر نهایی Firecracker microVM یا Kubernetes به همراه RuntimeClass/gVisor/Kata Containers بررسی شود. + +### 7.2 چرا مستقیم روی سرور اصلی اجرا نکنیم؟ + +چون کاربر قرار است نرم‌افزارش را در محیط اجرا کند. این یعنی: + +- امکان اجرای کد مخرب وجود دارد. +- امکان مصرف بی‌نهایت CPU/RAM/disk وجود دارد. +- امکان دسترسی به فایل‌های دیگران وجود دارد. +- امکان network abuse وجود دارد. +- امکان استخراج secretها وجود دارد. + +پس اجرای مستقیم روی host اصلی ممنوع است. + +### 7.3 سطح‌بندی isolation + +#### سطح 1: Container ساده + +مناسب فقط برای dev داخلی. برای untrusted code کافی نیست. + +#### سطح 2: Kubernetes namespace per organization + +کنترل‌ها: + +- namespace جدا برای هر سازمان یا workspace. +- RBAC جدا. +- NetworkPolicy با deny-by-default. +- ResourceQuota و LimitRange. +- PodSecurity. +- Secretهای جدا. + +مزیت: عملی و قابل scale. +ضعف: اگر kernel مشترک compromise شود، isolation کامل نیست. + +#### سطح 3: Container سخت‌شده با gVisor/Kata + +مزیت: امنیت بهتر از container معمولی. +ضعف: پیچیدگی عملیاتی بیشتر. + +#### سطح 4: Firecracker microVM per run/workspace + +مزیت: بهترین تعادل امنیت و سرعت برای multi-tenant untrusted workloads. +ضعف: ساخت و مدیریت infra پیچیده‌تر است. + +### 7.4 معماری پیشنهادی Sandbox Gateway + +`Sandbox Gateway` تنها API مجاز برای اجرای کد است: + +- `createWorkspace(org_id, project_id, template_id)` +- `startSession(workspace_id)` +- `runCommand(session_id, command, timeout, resources)` +- `readFile(session_id, path)` +- `writeFile(session_id, path, content)` +- `snapshot(session_id)` +- `pause(session_id)` +- `resume(session_id)` +- `destroy(session_id)` +- `getPreviewUrl(session_id, port)` + +قوانین: + +- هیچ Agent مستقیمی حق دسترسی به shell host ندارد. +- هر command دارای timeout و resource limit است. +- egress network با allowlist کنترل می‌شود. +- secretها فقط از Secret Broker تزریق می‌شوند، نه env آزاد. +- هر sandbox event برای billing و audit ثبت می‌شود. + +### 7.5 مدل ذخیره‌سازی محیط کاربر + +پیشنهاد: + +- source code در Git repository یا object storage versioned نگهداری شود. +- sandboxها disposable باشند. +- state مهم با snapshot/volume ذخیره شود. +- artifactها در object storage ذخیره شوند. +- database اپ مشتری، اگر لازم شد، به شکل service جدا و محدود provision شود. + +--- + +## 8. پیشنهاد زبان و تکنولوژی + +### 8.1 پاسخ کوتاه + +برای نسخه جدید، «فقط Python» پیشنهاد نمی‌شود. بهترین ترکیب: + +- TypeScript برای وب اپلیکیشن، UI، API layer و تجربه SaaS. +- Python برای AI runtime، Agent orchestration، evaluation، RAG و workerهای سنگین. + +### 8.2 گزینه پیشنهادی اصلی + +Frontend و BFF: + +- Next.js + TypeScript +- React Server Components در صورت نیاز +- Tailwind CSS یا design system اختصاصی +- TanStack Query برای data fetching سمت client +- Zustand یا Jotai برای state سبک UI + +Backend اصلی: + +- Python FastAPI یا Litestar برای AI Runtime API +- PostgreSQL +- Redis برای queue/cache/rate limit +- Temporal یا Prefect برای workflowهای طولانی؛ اگر ساده‌تر بخواهیم، Celery/Dramatiq در MVP +- SQLAlchemy + Alembic یا در مسیر TypeScript، Prisma/Drizzle + +AI Runtime: + +- Python +- Provider adapter برای OpenAI/Anthropic/Gemini/OpenRouter/local +- LangGraph یا OpenAI Agents SDK فقط در جاهایی که ارزش افزوده واقعی دارند، نه برای قفل شدن کل معماری +- tracing داخلی مستقل از framework + +Sandbox: + +- فاز اول: Daytona یا E2B پشت Sandbox Gateway +- فاز بعد: Kubernetes + gVisor/Kata یا Firecracker + +Billing: + +- Stripe Billing برای بازار بین‌المللی +- abstraction داخلی برای payment providerهای دیگر +- usage ledger داخلی مستقل از Stripe + +### 8.3 چرا TypeScript لازم است؟ + +- UI وب حرفه‌ای با Python-only سخت‌تر و کندتر توسعه می‌یابد. +- اکوسیستم SaaS dashboard، realtime، forms، tables، charts و component library در TypeScript بالغ‌تر است. +- type sharing بین API و frontend مفید است. +- استخدام/نگهداری frontend مدرن با TypeScript ساده‌تر است. + +### 8.4 چرا Python را حذف نکنیم؟ + +- AI orchestration، ابزارهای LLM، RAG، data processing و sandbox clientها در Python قوی هستند. +- کد فعلی تجربه زیادی در Python دارد. +- workerهای AI بهتر است Python بمانند. + +### 8.5 تصمیم پیشنهادی + +برای شروع در پوشه `web`: + +- `apps/web`: Next.js + TypeScript +- `apps/api`: FastAPI/Python یا NestJS/TypeScript؛ پیشنهاد من FastAPI برای نزدیکی به AI runtime است. +- `packages/shared`: schemaها و قراردادهای مشترک، اگر monorepo TypeScript انتخاب شود. +- `services/ai-runtime`: Python worker/runtime +- `services/sandbox-gateway`: wrapper برای Daytona/E2B/Firecracker + +اگر بخواهیم پیچیدگی MVP کمتر شود: + +- یک Next.js app برای UI و API routes سبک. +- یک FastAPI service برای AI runtime و sandbox orchestration. +- PostgreSQL و Redis. + +--- + +## 9. Design Pattern و معماری پیشنهادی + +### 9.1 الگوی اصلی + +پیشنهاد قطعی: + +- Clean Architecture +- Hexagonal Architecture / Ports and Adapters +- Domain-Driven Design برای bounded contextها +- Event-Driven Architecture برای billing، audit و observability +- Modular Monolith در فاز اول + +Microservice از روز اول پیشنهاد نمی‌شود، چون محصول هنوز در حال شکل‌گیری است. اما مرزبندی‌ها باید طوری باشد که بعداً هر context قابل جدا شدن باشد. + +### 9.2 Bounded Contextها + +#### Identity & Access + +Entityها: + +- User +- Organization +- Membership +- Role +- Permission +- Invite +- Session + +#### Billing & Usage + +Entityها: + +- Plan +- Subscription +- CreditWallet +- UsageEvent +- Invoice +- Payment +- RateCard + +#### AI Control Plane + +Entityها: + +- Provider +- Model +- ModelCredential +- RoutingPolicy +- Capability +- CostProfile +- SafetyPolicy + +#### Agent Workforce + +Entityها: + +- AgentTemplate +- Employee +- Manager +- ToolPermission +- PromptVersion +- Run +- Thread +- Task +- Artifact + +#### Sandbox + +Entityها: + +- SandboxTemplate +- Workspace +- SandboxSession +- CommandRun +- Snapshot +- PreviewUrl +- ResourceLimit + +#### Knowledge + +Entityها: + +- KnowledgeSource +- Document +- Chunk +- EmbeddingJob +- RetrievalPolicy +- DataPermission + +#### Observability & Audit + +Entityها: + +- AuditEvent +- Trace +- Span +- ToolCall +- ErrorEvent +- QualityMetric + +### 9.3 Ports and Adapters + +نمونه portها: + +- `ModelProviderPort` +- `PaymentProviderPort` +- `SandboxProviderPort` +- `StoragePort` +- `EmailPort` +- `VectorStorePort` +- `NotificationPort` + +نمونه adapterها: + +- `OpenAIAdapter` +- `AnthropicAdapter` +- `GeminiAdapter` +- `StripeAdapter` +- `DaytonaSandboxAdapter` +- `E2BSandboxAdapter` +- `S3StorageAdapter` +- `PostgresVectorAdapter` + +مزیت: + +- Provider عوض می‌شود، domain logic عوض نمی‌شود. +- مدل از مشتری پنهان می‌ماند. +- sandbox backend قابل تعویض است. + +### 9.4 Design Patternهای کاربردی + +#### Strategy Pattern + +برای انتخاب مدل، Provider، pricing و execution policy. + +مثال: + +- `FastCheapRoutingStrategy` +- `HighReasoningRoutingStrategy` +- `VisionRoutingStrategy` +- `FallbackRoutingStrategy` + +#### Factory Pattern + +برای ساخت Agent/Employee از template. + +#### State Machine + +برای Task و Run lifecycle. + +وضعیت‌های پیشنهادی Run: + +- queued +- planning +- waiting_approval +- running +- tool_calling +- sandbox_running +- completed +- failed +- cancelled +- timed_out + +#### Saga / Workflow Pattern + +برای taskهای چندمرحله‌ای: + +- دریافت درخواست +- تحلیل +- انتخاب کارمند +- اجرای sandbox +- تولید artifact +- محاسبه مصرف +- ثبت audit +- گرفتن approval +- تحویل خروجی + +#### Outbox Pattern + +برای اینکه usage/audit/billing eventها از بین نروند. + +#### Repository Pattern + +برای جدا کردن domain از دیتابیس. + +#### Policy Object + +برای approval، access control، budget cap، model routing و sandbox limits. + +--- + +## 10. معماری سطح بالا + +```text +Browser / Web UI + | + | HTTPS + v +Web App / BFF + | + | Authenticated API + v +Application Services + |-------------------------| + | | + v v +Domain Modules Event Bus / Outbox + | | + v v +PostgreSQL Workers + | + |----------------|----------------| + v v v + AI Runtime Sandbox Gateway Billing Processor + | | | + v v v + Model Providers E2B/Daytona/etc. Stripe/Payment Adapter +``` + +--- + +## 11. مدل داده اولیه + +### 11.1 User و Organization + +- `users` +- `organizations` +- `memberships` +- `roles` +- `permissions` +- `invites` + +### 11.2 AI + +- `ai_providers` +- `ai_model_credentials` +- `ai_models` +- `routing_policies` +- `agent_templates` +- `employees` +- `employee_tools` +- `prompt_versions` + +### 11.3 Task و Run + +- `projects` +- `threads` +- `tasks` +- `runs` +- `run_steps` +- `tool_calls` +- `artifacts` + +### 11.4 Sandbox + +- `sandbox_templates` +- `workspaces` +- `sandbox_sessions` +- `command_runs` +- `snapshots` +- `preview_urls` + +### 11.5 Billing + +- `plans` +- `subscriptions` +- `credit_wallets` +- `usage_events` +- `invoices` +- `payments` +- `rate_cards` + +### 11.6 Audit و Observability + +- `audit_events` +- `traces` +- `spans` +- `error_events` +- `quality_metrics` + +--- + +## 12. تجربه کاربری پیشنهادی + +### 12.1 صفحات اصلی + +1. Landing / Login +2. Organization Switcher +3. Dashboard +4. AI Workforce +5. Manager Chat +6. Tasks +7. Projects +8. Sandbox / App Runtime +9. Knowledge Base +10. Billing +11. Settings +12. Admin Console برای مالک سامانه + +### 12.2 اصول طراحی UI + +- کاربر نباید Provider و model id ببیند. +- به جای «run model» باید ببیند «کارمند در حال انجام کار است». +- هر خروجی باید artifact واضح داشته باشد. +- cost و مصرف باید شفاف اما ساده باشد. +- کارهای خطرناک باید approval modal داشته باشند. +- dashboard باید حس کنترل و اعتماد بدهد. + +### 12.3 تم و طراحی بصری + +پیشنهاد برای UI: + +- Dark-first professional dashboard. +- sidebar ثابت برای Organization، Workforce، Tasks، Projects، Billing. +- command palette برای حرکت سریع. +- کارت کارمندها با status، توانایی‌ها، مصرف امروز و taskهای فعال. +- timeline برای runها. +- split view برای chat + artifacts + logs. + +--- + +## 13. مسیر توسعه پیشنهادی + +### فاز صفر: آماده‌سازی پوشه web + +خروجی: + +- ساختار monorepo. +- انتخاب stack. +- setup lint/test/typecheck. +- طراحی schema اولیه. + +### فاز یک: SaaS Shell + +قابلیت‌ها: + +- auth +- organization +- membership +- dashboard اولیه +- settings +- PostgreSQL + +### فاز دو: AI Control Plane + +قابلیت‌ها: + +- Provider مخفی برای مالک سامانه. +- Model registry. +- Routing policy. +- Agent template. +- Employee creation. + +### فاز سه: Manager Chat و Run Engine + +قابلیت‌ها: + +- chat با مدیر. +- task creation. +- run lifecycle. +- streaming response. +- artifact ساده. +- trace پایه. + +### فاز چهار: Sandbox Gateway + +قابلیت‌ها: + +- اتصال به Daytona یا E2B. +- اجرای command. +- file read/write. +- preview URL. +- snapshot. +- resource limits. + +### فاز پنج: Billing + +قابلیت‌ها: + +- usage event. +- credit wallet. +- rate card. +- invoice آماده‌سازی. +- Stripe integration یا adapter پرداخت. + +### فاز شش: Knowledge و Connectors + +قابلیت‌ها: + +- upload docs. +- vector indexing. +- permission-aware retrieval. +- اتصال GitHub/Git/GDrive در آینده. + +### فاز هفت: Governance و کیفیت + +قابلیت‌ها: + +- approvals. +- audit logs. +- human-in-the-loop. +- replay/debug. +- evaluation metrics. + +--- + +## 14. پیشنهاد ساختار پوشه web + +```text +web/ + PLAN.md + apps/ + web/ + src/ + app/ + components/ + features/ + lib/ + styles/ + api/ + app/ + main.py + modules/ + infrastructure/ + domain/ + application/ + services/ + ai-runtime/ + sandbox-gateway/ + billing-worker/ + packages/ + contracts/ + ui/ + infra/ + docker/ + migrations/ + docs/ + architecture/ + product/ +``` + +اگر بخواهیم ساده‌تر شروع کنیم: + +```text +web/ + PLAN.md + frontend/ + backend/ + workers/ + infra/ +``` + +پیشنهاد من همان ساختار `apps` و `services` است، چون برای محصول آینده تمیزتر است. + +--- + +## 15. تصمیم‌های پیشنهادی اولیه + +1. محصول جدید مستقل از Telegram Bot فعلی باشد. +2. هیچ فایل اصلی نسخه فعلی در runtime نسخه جدید import نشود. +3. نام مفهومی محصول: AI Workforce Platform یا AGY Web. +4. زبان محصولی: کارمند، مدیر، سازمان، پروژه، وظیفه، خروجی؛ نه model، prompt، token برای کاربر عادی. +5. stack پیشنهادی: Next.js/TypeScript + FastAPI/Python + PostgreSQL + Redis. +6. معماری: Modular Monolith با Clean Architecture و Ports/Adapters. +7. sandbox: ابتدا Daytona/E2B پشت Sandbox Gateway؛ سپس Firecracker/Kubernetes در مقیاس بالا. +8. billing: usage ledger داخلی از روز اول، حتی اگر payment integration بعداً وصل شود. +9. observability: trace و audit از روز اول، نه بعداً. +10. UI: داشبورد dark، سریع، شفاف و سازمان‌محور. + +--- + +## 16. مواردی که نباید از پروژه فعلی تکرار شوند + +1. UI logic داخل handlerهای پیام. +2. stateهای حساس داخل memory process. +3. coupling به platform خارجی مثل Telegram. +4. اجرای کد کاربر روی host اصلی. +5. hardcode مدل‌ها در فایل‌های runtime. +6. migration پنهان و پراکنده داخل فایل‌های domain. +7. ترکیب auth، UI، API، engine و billing در یک فایل بزرگ. +8. وابستگی business logic به شناسه‌های تلگرام. +9. نبود ledger رسمی برای usage و billing. +10. نبود trace کامل برای تصمیم‌های Agent. + +--- + +## 17. ریسک‌ها و کنترل‌ها + +### 17.1 ریسک امنیت sandbox + +کنترل: + +- هیچ command بدون Sandbox Gateway اجرا نشود. +- deny-by-default network. +- secret injection محدود. +- resource quota. +- teardown خودکار. + +### 17.2 ریسک هزینه مدل‌ها + +کنترل: + +- budget cap در سطح سازمان/کارمند/task. +- routing policy. +- cache و summarization. +- هشدار مصرف. + +### 17.3 ریسک پیچیدگی معماری + +کنترل: + +- Modular Monolith در شروع. +- boundary واضح بین contextها. +- event bus داخلی ساده. +- microservice فقط بعد از فشار واقعی. + +### 17.4 ریسک تجربه کاربری پیچیده + +کنترل: + +- کاربر عادی فقط مدیر، کارمند، task، فایل و خروجی ببیند. +- تنظیمات مدل فقط برای مالک سامانه و adminهای فنی. +- onboarding مرحله‌ای. + +--- + +## 18. MVP پیشنهادی + +MVP نباید همه چیز را داشته باشد. MVP مناسب: + +1. ثبت‌نام و ورود. +2. ساخت سازمان. +3. خرید/افزایش اعتبار نمایشی یا واقعی. +4. داشبورد سازمان. +5. تعریف Provider مخفی توسط مالک سامانه. +6. ساخت چند کارمند آماده. +7. chat با مدیر. +8. ساخت task. +9. اجرای یک task توسط کارمند AI. +10. ثبت usage event. +11. نمایش مصرف. +12. اجرای command ساده در sandbox. +13. نمایش artifact خروجی. +14. audit log پایه. + +چیزهایی که می‌توانند بعد از MVP بیایند: + +- marketplace کامل. +- connectorهای زیاد. +- workflow builder تصویری. +- fine-grained data permission. +- self-host sandbox infra. +- advanced evaluation. + +--- + +## 19. تعریف موفقیت نسخه اول + +نسخه اول زمانی موفق است که: + +1. کاربر بدون تلگرام وارد شود. +2. سازمان بسازد. +3. اعتبار/پلن داشته باشد. +4. یک مدیر AI ببیند. +5. یک task واقعی بسپارد. +6. backend بدون نمایش مدل، model routing انجام دهد. +7. task run شود و خروجی/artifact بدهد. +8. مصرف ثبت شود. +9. اگر اجرای کد لازم بود، داخل sandbox انجام شود. +10. مدیر سامانه بتواند Provider، مدل، تعرفه و مصرف کل را کنترل کند. + +--- + +## 20. قدم بعدی پیشنهادی + +قدم بعدی بعد از این پلن: + +1. تصمیم نهایی درباره stack: Next.js + FastAPI یا Next.js + NestJS. +2. ساخت اسکلت پروژه داخل `web`. +3. طراحی ERD اولیه برای Organization، User، Employee، Run، Usage. +4. طراحی UI wireframe برای dashboard، workforce و manager chat. +5. پیاده‌سازی auth و organization. +6. پیاده‌سازی اولین Provider adapter و اولین Manager Agent. +7. اتصال اولین Sandbox Provider پشت Gateway. + +پیشنهاد اجرایی من: از فاز SaaS Shell شروع کنیم، نه از Agent engine. اگر shell، auth، organization و data model درست ساخته شود، بقیه قابلیت‌ها تمیز روی آن سوار می‌شوند. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..436b766 --- /dev/null +++ b/README.md @@ -0,0 +1,332 @@ +# AGY Web — پلتفرم نیروی کار هوش مصنوعی + +نسخهٔ وب مستقل و چندسازمانی محصول. این پروژه **کاملاً جدا از ربات تلگرام** است و +هیچ فایلی از ریشهٔ مخزن را در runtime خود import نمی‌کند. + +- پلن محصول و معماری: [`PLAN.md`](./PLAN.md) +- چک‌لیست اجرایی کامل: [`TODO.md`](./TODO.md) +- یادداشت زیرساخت فعلی (فقط مرجع): [`docs/current-infra-notes.md`](./docs/current-infra-notes.md) + +--- + +## ۱. محصول چیست + +یک وب اپلیکیشن چندسازمانی برای «نیروی کار هوش مصنوعی سازمانی». + +**کاربر نهایی این‌ها را می‌بیند:** سازمان، مدیر هوش مصنوعی، کارمندهای هوش مصنوعی، +پروژه، وظیفه، فایل/خروجی، مصرف و پرداخت. + +**کاربر نهایی این‌ها را نمی‌بیند:** نام مدل، provider، `base_url`، API key، قیمت خام +provider. این‌ها فقط در **Admin Console** و فقط برای مالک سامانه قابل مدیریت‌اند. + +پلتفرم سه لایهٔ نقش دارد: + +1. **مالک سامانه (platform owner)** — providerها، مدل‌ها، routing policy، تعرفه‌ها، + sandbox templateها، مشاهدهٔ کل مصرف. +2. **مدیر سازمان مشتری** — ساخت سازمان، دعوت عضو، پروژه، فعال‌سازی کارمند، مصرف. +3. **عضو سازمان** — گفتگو با مدیر/کارمند، ساخت task، آپلود فایل، دیدن خروجی. + +--- + +## ۲. پیش‌نیازها + +| ابزار | نسخه | +|---|---| +| Node.js | >= 20 | +| pnpm | 9.x | +| Python | 3.13 | +| Docker + Compose | برای postgres/redis | + +فعال‌سازی pnpm در صورت نبود: + +```bash +corepack enable pnpm +corepack prepare pnpm@9.12.0 --activate +``` + +--- + +## ۳. راه‌اندازی سریع + +```bash +# ۱) نصب وابستگی‌های JS +pnpm install + +# ۲) ساخت venv پایتون برای backend +pnpm api:venv + +# ۳) کپی فایل env و پر کردن مقادیر (پیش‌فرض‌ها همه mock هستند) +cp .env.example .env + +# ۴) بالا آوردن postgres و redis +docker compose -f infra/docker/docker-compose.yml up -d postgres redis + +# ۵) اجرای migration و seed +pnpm db:migrate +pnpm db:seed + +# ۶) اجرای هم‌زمان frontend و backend +pnpm dev +``` + +آدرس‌ها پس از اجرا: + +- Frontend: +- API: +- API docs (Swagger): +- Health: + +### حساب‌های نمونه (فقط محیط توسعه) + +`pnpm db:seed` دو حساب می‌سازد و **هر دو عضو سازمان نمونه هستند**، پس بعد از ورود +بلافاصله یک داشبورد پر می‌بینید: + +| نقش | ایمیل | رمز | +|---|---|---| +| کاربر نمونه | `demo@example.com` | `demo12345` | +| مالک سامانه (admin console) | `owner@example.com` | `owner12345` | + +> این‌ها رمزهای **توسعه** هستند و در `app/seed.py` سخت‌کد شده‌اند. هیچ‌وقت در +> production از این‌ها استفاده نکنید و `JWT_SECRET` / `ENCRYPTION_KEY` واقعی +> بگذارید (نگهبان config در حالت production مقادیر placeholder را رد می‌کند). +> +> نکته: دامنهٔ ایمیل عمداً `example.com` است. اگر `.local` یا `.test` بگذارید، +> `email-validator` آن را رد می‌کند و حساب ساخته می‌شود ولی **ورود به آن ناممکن +> است**. `tests/test_seed.py` همین را تضمین می‌کند. + +### اجرای کل استک با Docker + +```bash +docker compose -f infra/docker/docker-compose.yml up --build +``` + +--- + +## ۴. اسکریپت‌ها + +| دستور | کار | +|---|---| +| `pnpm dev` | اجرای هم‌زمان Next.js و FastAPI | +| `pnpm build` | build فرانت‌اند | +| `pnpm lint` | ESLint (web) + ruff (api) | +| `pnpm typecheck` | tsc (web) + mypy (api) | +| `pnpm test` | vitest (web) + pytest (api) | +| `pnpm format` | Prettier (web) + ruff format (api) | +| `pnpm db:migrate` | `alembic upgrade head` | +| `pnpm db:seed` | داده‌های نمونه (platform owner، کاربر نمونه، سازمان، templateها) | +| `pnpm api:venv` | ساخت venv پایتون | +| `pnpm worker:billing` | یک پاس worker صورتحساب | +| `pnpm worker:ai-runtime` | یک پاس worker اجرای taskها | +| `pnpm worker:sandbox-gateway` | یک پاس پاک‌سازی sessionهای بی‌استفاده | + +--- + +## ۵. معماری + +### ۵.۱ سبک معماری + +- **Clean Architecture** + **Hexagonal / Ports & Adapters** +- **DDD** برای مرزبندی bounded contextها +- **Event-Driven** برای billing، audit و observability (الگوی Outbox) +- **Modular Monolith** در فاز اول — microservice زودهنگام ممنوع + +قاعدهٔ سخت: لایهٔ `domain` هیچ dependency به FastAPI، SQLAlchemy، Redis، HTTP یا +provider خارجی ندارد. فقط `application` و `infrastructure` به آن وابسته‌اند، نه برعکس. + +### ۵.۲ Bounded Contextها + +`identity` · `billing` · `ai_control` · `workforce` · `sandbox` · `knowledge` · +`audit` · `projects` · `platform` + +### ۵.۳ Ports الزامی + +`ModelProviderPort` · `PaymentProviderPort` · `SandboxProviderPort` · +`GitProviderPort` · `DomainRouterPort` · `StoragePort` · `VectorStorePort` · +`NotificationPort` · `AuditPort` · `UsageLedgerPort` + +هر port حداقل یک adapter **mock** دارد تا کل استک بدون هیچ credential واقعی اجرا شود. + +### ۵.۴ جریان درخواست + +```text +Browser → Web (Next.js/BFF) → API (FastAPI) + ├── Domain Modules + ├── Event Bus / Outbox → Workers + ├── AI Runtime → Model Providers + ├── Sandbox Gateway → E2B / Daytona + └── Billing Processor → Stripe +``` + +--- + +## ۶. ساختار پوشه + +```text +web/ + PLAN.md پلن محصول و معماری + TODO.md چک‌لیست اجرایی + README.md همین فایل + package.json اسکریپت‌های ریشه + pnpm-workspace.yaml تعریف workspace + .env.example نمونهٔ env (هیچ secret واقعی ندارد) + apps/ + web/ Next.js + TypeScript (UI) + api/ FastAPI + SQLAlchemy (backend) + services/ + ai-runtime/ worker اجرای Agent + sandbox-gateway/ wrapper ایزوله‌سازی اجرا + billing-worker/ پردازش usage و invoice + packages/ + contracts/ تایپ‌ها و قراردادهای مشترک + ui/ componentهای پایه (design system) + infra/ + docker/ docker-compose + migrations/ محل migrationها + docs/ + architecture/ مستندات معماری + product/ مستندات محصول + current-infra-notes.md +``` + +--- + +## ۷. کجا کد بنویسیم + +| می‌خواهم... | محل | +|---|---| +| صفحه یا component UI بسازم | `apps/web/src/app` یا `apps/web/src/features` | +| component پایه/design system | `packages/ui` | +| endpoint جدید بسازم | `apps/api/app/interfaces/http` | +| use case بنویسم | `apps/api/app/application` | +| منطق دامنه بنویسم | `apps/api/app/domain/` | +| adapter یا اتصال به سرویس بیرونی | `apps/api/app/infrastructure` | +| worker پس‌زمینه بنویسم | `apps/api/app/workers` (پوستهٔ اجرا در `services/`) | +| خطا ثبت/پاک‌سازی کنم | `apps/api/app/core/redact.py` + `apps/api/app/infrastructure/error_log.py` | +| قرارداد مشترک front/back | `packages/contracts` | + +**ممنوع:** نوشتن هر کد جدید در فایل‌های ریشهٔ مخزن (`bot.py`, `web_panel.py`, +`bot_menus.py`, `bot_messages.py`) و هر وابستگی به `telegram`, `chat_id`, +`callback_data`, `InlineKeyboardMarkup`. + +--- + +## ۸. سرویس‌های خارجی و حالت mock + +همهٔ سرویس‌های بیرونی پیش‌فرض روی `mock` هستند. برای فعال‌سازی واقعی، این envها را +پر کنید: + +| قابلیت | env | مقادیر | +|---|---|---| +| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` | +| Git | `GIT_PROVIDER` | `mock` \| `gitea` | +| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` | +| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | +| پرداخت | `PAYMENT_PROVIDER` | `mock` \| `stripe` | + +اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و +adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند. + +--- + +## ۹. Workerهای پس‌زمینه + +سه worker وجود دارد. همه در `apps/api/app/workers/` پیاده شده‌اند و `services/` +پوستهٔ اجرای آن‌هاست: + +| worker | کار | دستور | +|---|---|---| +| `billing-worker` | جمع‌بندی usage ledger به فاکتور draft (هر دوره یک‌بار، idempotent) | `pnpm worker:billing` | +| `ai-runtime` | برداشتن taskهای در صف و اجرای آن‌ها از مسیر run engine | `pnpm worker:ai-runtime` | +| `sandbox-gateway` | متوقف‌کردن sessionهای بی‌استفاده و ثبت audit | `pnpm worker:sandbox-gateway` | + +هر دستور **یک پاس** اجرا می‌کند و خارج می‌شود (شکل مناسب cron). برای اجرای +مستمر `--loop` بدهید: + +```bash +cd apps/api && .venv/bin/python -m app.workers billing --loop --interval 3600 +``` + +**اصل مهم:** workerها هیچ منطق تجاری ندارند. هر سه از +`app/application/container.py` همان سرویس‌هایی را می‌سازند که HTTP API می‌سازد، +پس worker و API هرگز نمی‌توانند دربارهٔ اینکه کدام adapter پشت یک port است اختلاف +داشته باشند. جزئیات هر کدام در `services/*/README.md`. + +--- + +## ۱۰. اصول تغییرناپذیر + +1. اجرای کد کاربر یا agent روی host اصلی **ممنوع** — فقط از مسیر Sandbox Gateway. +2. هر endpoint چندسازمانی باید `organization_id` را enforce کند. +3. هیچ secret، token یا API key در frontend یا لاگ چاپ نمی‌شود. +4. هر عملیات destructive باید confirmation و audit log داشته باشد. +5. مدل و provider از دید کاربر عادی پنهان است. +6. هر مصرفی در usage ledger داخلی ثبت می‌شود (Stripe فقط adapter است). + +--- + +## ۱۱. کنسول خطا + +هر خطایی که سامانه بگیرد — در درخواست HTTP، در worker پس‌زمینه، یا در زمان +راه‌اندازی — با جزئیات کامل در جدول `error_events` ثبت می‌شود و در +`/admin/errors` (فقط مالک سامانه) قابل مشاهده است. + +**چه چیزی ثبت می‌شود** + +| منبع | چه زمانی | شدت | +| --- | --- | --- | +| استثنای مدیریت‌نشده و خطاهای ۵xx | همیشه | `critical` / `error` | +| `ValueError` بدون نوع مشخص | همیشه | `warning` | +| سایر پاسخ‌های ۴xx | فقط با `ERROR_LOG_CLIENT_ERRORS=1` | `warning` | + +۴xx به‌صورت پیش‌فرض ثبت نمی‌شود چون یک API عمومی دائماً ۴۰۴ از اسکنرها +می‌گیرد و ثبت همهٔ آن‌ها خطاهای واقعی را زیر خود دفن می‌کند. خطاهای ۵xx +همیشه ثبت می‌شوند، مستقل از این تنظیم. + +**هر رکورد چه چیزی دارد** + +- `kind`، `error_type`، `message` و `stack` (ردِّ کامل خطا) +- `method`، `path`، `status_code` و `request_id` +- `organization_id` و `user_id` (پس از احراز هویت) +- `fingerprint` — هش پایدار از هویت خطا، برای گروه‌بندی +- `metadata_json` — جزئیات اضافی (مثلاً `task_id`) + +**گروه‌بندی و رسیدگی** + +خطاهای یکسان بر اساس `fingerprint` در یک ردیف جمع می‌شوند، پس یک باگ که ۴۰۰۰ +بار رخ داده یک خط با شمارنده است، نه ۴۰۰۰ خط. `fingerprint` عمداً سازمان و +کاربر را در نظر نمی‌گیرد: یک باگ که دو مستأجر را می‌زند هنوز یک باگ است. +بستن یک گروه همهٔ رخدادهای باز آن را می‌بندد و در audit log ثبت می‌شود. + +**پاک‌سازی خودکار secretها** + +هیچ خطایی بدون عبور از `app/core/redact.py` ذخیره نمی‌شود. سه لایه: +کلیدهایی که معنایشان secret است (`password`، `api_key`، …)، الگوهای +شناختنی (JWT، `Bearer`، `scheme://user:pass@host`، `sk-…`، `ghp_…`)، و +خودِ مقادیر secret تنظیم‌شده در `settings`. یک لاگ خطا که رمز عبور ذخیره کند +از نداشتن لاگ خطا بدتر است. + +**استفاده از `request_id`** + +هر پاسخ `X-Request-ID` دارد و هر بدنهٔ خطا هم همان شناسه را در +`error.request_id` برمی‌گرداند. کاربر می‌تواند فقط همین یک رشته را گزارش کند +تا دقیقاً یک رکورد پیدا شود. + +```bash +# ۵۰۰ بگیر، سپس با همان شناسه رکورد را پیدا کن +curl -i http://localhost:8000/api/whatever -H "X-Request-ID: my-trace" +curl "http://localhost:8000/api/admin/errors?search=my-trace" -H "Authorization: Bearer $OWNER_TOKEN" +``` + +**اجرای دستی workerها و ثبت خطا** + +خطای یک pass کامل با `source=worker` ثبت می‌شود و exit code غیرصفر می‌دهد تا +cron متوجه شود. خطای یک task مشخص نیز با `task_id` و سازمان مربوطه ثبت +می‌شود. + +--- + +## ۱۲. مستندات + +- [`docs/architecture/overview.md`](./docs/architecture/overview.md) — نمای کلی معماری +- [`docs/security-checklist.md`](./docs/security-checklist.md) — چک‌لیست امنیتی +- [`docs/current-infra-notes.md`](./docs/current-infra-notes.md) — زیرساخت فعلی (مرجع) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..bdf67db --- /dev/null +++ b/TODO.md @@ -0,0 +1,1435 @@ +# TODO اجرای کامل نسخه وب مستقل AGY + +> **وضعیت (به‌روزرسانی نهایی):** پیاده‌سازی کامل شده است. +> +> - بک‌اند: **۲۱۶ تست سبز**، `ruff check` تمیز، `mypy` روی **۹۰ فایل** بدون خطا. +> - فرانت‌اند: **۲۵ تست سبز**، `next lint` بدون هشدار، `tsc --noEmit` تمیز. +> - دو migration در `apps/api/alembic/versions/` ساخته و با `alembic check` +> تأیید شد که هیچ drift ندارد. مسیر upgrade و downgrade هر دو تست شده‌اند. +> - workerها (`billing-worker`, `ai-runtime`, `sandbox-gateway`) واقعی و تست‌شده‌اند. +> - مستندات: `docs/architecture/overview.md` و `docs/security-checklist.md` نوشته شدند. +> +> **افزودهٔ بعد از بستن مراحل (درخواست کاربر):** ثبت کامل خطا در دیتابیس. +> هر خطایی — در HTTP، در worker، در راه‌اندازی — با جزئیات کامل +> (ردِّ خطا، `request_id`، سازمان، کاربر، مسیر، اثر انگشت) در جدول +> `error_events` ذخیره می‌شود و در `/admin/errors` قابل مشاهده و رسیدگی است. +> هیچ خطایی بدون پاک‌سازی secret ذخیره نمی‌شود (`app/core/redact.py`). +> +> **چیزهایی که عمداً باز مانده‌اند:** در `docs/security-checklist.md` دو مورد +> «⚠️ بدون تست اختصاصی» علامت خورده‌اند (نبود secret در frontend، و مقدار پیش‌فرض +> `NetworkPolicy.DENY_ALL`). هر دو کنترل در کد درست کار می‌کنند، فقط تست مستقل +> ندارند. هیچ‌کدام شکاف امنیتی فعال نیست. +> +> **نکتهٔ صداقت:** چک‌باکس‌های زیر **معیارهای پذیرش**، **§۲۲**، **§۲۳** و **§۰.۲** +> علامت خورده‌اند چون مستقیماً تأیید شده‌اند. بندهای ریزتر داخل بدنهٔ هر مرحله +> (۵۰۰ بند توصیفی) به‌عنوان شرح کار باقی مانده‌اند و علامت نخورده‌اند؛ ادعای +> تأیید تک‌تک آن‌ها بدون شاهد، خودش یک نقض صداقت مستندسازی می‌بود. + +این فایل برای اجرای مرحله‌به‌مرحله توسط یک عامل کدنویس سبک مثل **DeepSeek v4.1 Flash** نوشته شده است. +هدف: ساخت نسخه وب مستقل و کامل محصول، بدون وابستگی runtime به ربات تلگرام فعلی. + +> فایل مرجع محصول و معماری: `web/PLAN.md` +> محل مجاز توسعه: فقط داخل پوشه `web/` مگر در مواردی که صراحتاً گفته شده است. +> زبان رابط کاربری: فارسی، راست‌به‌چپ، حرفه‌ای، سریع و ساده. +> نکته مهم: سیستم فعلی already کدهایی برای **route دامنه‌های تست** و **Git/Gitea** دارد. در نسخه وب جدید همه سازمان‌ها/کاربران باید بتوانند از این امکانات از طریق وب استفاده کنند، اما کد جدید نباید به تلگرام وابسته شود. + +--- + +## 0. قوانین قطعی قبل از شروع + +### 0.1 محدودیت‌های اصلی + +- [ ] هیچ قابلیت جدیدی را داخل فایل‌های قدیمی ریشه پروژه مثل `bot.py`، `web_panel.py`، `bot_menus.py`، `bot_messages.py` پیاده‌سازی نکن. +- [ ] نسخه جدید باید داخل `web/` ساخته شود. +- [ ] هیچ منطق جدیدی نباید به `telegram`, `chat_id`, `callback_data`, `InlineKeyboardMarkup`, `MessageHandler`, `CallbackQueryHandler` وابسته باشد. +- [ ] اگر لازم است از منطق فعلی Git یا domain routing استفاده شود، فقط آن را به عنوان reference بخوان و در `web/` یک abstraction تمیز و مستقل بساز. +- [ ] هیچ API key، token، secret، credential یا password را در frontend یا لاگ‌ها چاپ نکن. +- [ ] اجرای کد کاربر یا agent روی host اصلی ممنوع است؛ همه چیز باید از مسیر Sandbox Gateway عبور کند. +- [ ] برای هر عملیات destructive مثل حذف سازمان، حذف repo، حذف دامنه، حذف sandbox یا پاک‌کردن artifact باید confirmation و audit log وجود داشته باشد. +- [ ] هر endpoint چندسازمانی باید `organization_id` را enforce کند؛ هیچ کاربر نباید داده سازمان دیگر را ببیند. + +### 0.2 تعریف Done برای کل پروژه + +پروژه زمانی کامل است که: + +- [x] کاربر بدون تلگرام ثبت‌نام و ورود کند. +- [x] کاربر بتواند سازمان بسازد و عضو دعوت کند. +- [x] مدیر سامانه بتواند provider/model/routing/price را پنهانی مدیریت کند. +- [x] کاربر عادی مدل خام نبیند؛ فقط مدیر و کارمندان AI را ببیند. +- [x] سازمان بتواند پروژه بسازد. +- [x] هر پروژه بتواند repo خصوصی Git داشته باشد. +- [x] هر پروژه بتواند یک دامنه تست/preview بگیرد. +- [x] هر پروژه بتواند یک محیط اجرای ایزوله بگیرد. +- [x] کاربر بتواند با Manager Chat صحبت کند و task بسازد. +- [x] یک task واقعی توسط AI worker اجرا شود. +- [x] usage event برای model call، sandbox runtime، storage/artifact و tool call ثبت شود. +- [x] داشبورد مصرف، run history، audit log و billing قابل مشاهده باشد. +- [x] build، lint، typecheck و تست‌های اصلی بدون خطا اجرا شوند. + +--- + +## 1. شناخت کد فعلی فقط برای reference + +کد فعلی نباید runtime پایه نسخه جدید باشد، اما این فایل‌ها را برای فهم زیرساخت بخوان: + +- [ ] `caddy_manager.py` + - کار فعلی: مدیریت Caddy reverse proxy و ساخت route برای subdomain. + - نکات مهم: + - دامنه wildcard فعلی: `msa.artacloud.ir` + - فقط hostهای تک‌label زیر این دامنه مجاز هستند. + - target فقط باید `localhost:` یا `127.0.0.1:` باشد. + - قبل از reload، Caddyfile validate می‌شود. +- [ ] `port_registry.py` + - کار فعلی: تخصیص port و ledger مالکیت subdomain. + - نکات مهم: + - port range فعلی: `8080-8999` + - protected ports شامل `3000`, `35555`, `35556`, `35557`, `80`, `443`, `5432`, `6379` و موارد سیستمی است. + - جدول مفهومی `subdomain_registry` برای جلوگیری از hijack دامنه وجود دارد. + - هر پروژه حداکثر چند subdomain می‌گیرد؛ مقدار فعلی `MAX_SUBDOMAINS_PER_PROJECT = 5` است. +- [ ] `git_manager.py` + - کار فعلی: ساخت repo خصوصی Gitea، credential پروژه، git sync/commit/push، lock repo. + - نکات مهم: + - Gitea URL از config خوانده می‌شود. + - repo باید private باشد. + - credential پروژه باید per owner/project پایدار و collision-free باشد. +- [ ] `gitea_api.py` + - کار فعلی: API سبک برای PR، issue، comment و merge. + - نکات مهم: + - token نباید در خروجی یا prompt دیده شود. + - PR workflow و issue ingestion قابل استفاده برای نسخه جدید است. + +### خروجی این مرحله + +- [ ] در `web/docs/current-infra-notes.md` خلاصه‌ای 1 تا 2 صفحه‌ای از domain routing و Git فعلی بنویس. +- [ ] صراحتاً بنویس که نسخه جدید این کدها را مستقیماً import نمی‌کند، بلکه adapter مستقل خودش را می‌سازد. + +--- + +## 2. ساختار پروژه داخل web + +### 2.1 ساخت پوشه‌ها + +داخل `web/` این ساختار را بساز: + +```text +web/ + TODO.md + PLAN.md + README.md + package.json + pnpm-workspace.yaml + apps/ + web/ + api/ + services/ + ai-runtime/ + sandbox-gateway/ + billing-worker/ + packages/ + contracts/ + ui/ + infra/ + docker/ + migrations/ + docs/ + architecture/ + product/ + current-infra-notes.md +``` + +### 2.2 README اولیه + +- [ ] در `web/README.md` بنویس: + - محصول چیست. + - چطور dev server اجرا می‌شود. + - چطور test/lint/typecheck اجرا می‌شود. + - معماری کلی چیست. + - کجا باید کد frontend/backend/worker نوشته شود. + +### 2.3 package manager + +- [ ] از `pnpm` استفاده کن. +- [ ] root `package.json` باید scriptهای زیر داشته باشد: + - `dev` + - `build` + - `lint` + - `typecheck` + - `test` + - `format` + - `db:migrate` + - `db:seed` + +### پذیرش مرحله 2 + +- [x] اجرای `pnpm install` بدون خطا. +- [x] اجرای `pnpm lint` حداقل بدون خطای ساختاری. +- [x] همه پوشه‌ها و README ساخته شده باشند. + +--- + +## 3. انتخاب stack و bootstrapping + +### 3.1 Frontend + +در `web/apps/web` بساز: + +- [ ] Next.js + TypeScript +- [ ] App Router +- [ ] Tailwind CSS +- [ ] ESLint +- [ ] Prettier +- [ ] RTL support +- [ ] Dark-first design +- [ ] Component پایه داخل `packages/ui` + +صفحات اولیه: + +- [ ] `/login` +- [ ] `/signup` +- [ ] `/onboarding` +- [ ] `/dashboard` +- [ ] `/workforce` +- [ ] `/manager` +- [ ] `/tasks` +- [ ] `/projects` +- [ ] `/knowledge` +- [ ] `/billing` +- [ ] `/settings` +- [ ] `/admin` + +### 3.2 Backend API + +در `web/apps/api` بساز: + +- [ ] Python FastAPI +- [ ] SQLAlchemy 2.x +- [ ] Alembic +- [ ] Pydantic v2 +- [ ] JWT/session auth +- [ ] pytest +- [ ] ruff +- [ ] mypy یا pyright اگر ساده بود + +ساختار backend: + +```text +apps/api/app/ + main.py + core/ + config.py + security.py + database.py + errors.py + domain/ + identity/ + billing/ + ai_control/ + workforce/ + sandbox/ + knowledge/ + audit/ + projects/ + platform/ + application/ + infrastructure/ + interfaces/ + http/ +``` + +### 3.3 دیتابیس و Redis + +- [ ] در `infra/docker/docker-compose.yml` سرویس‌های زیر را بساز: + - postgres + - redis + - api + - web +- [ ] env نمونه بساز: `web/.env.example` +- [ ] secret واقعی داخل repo نگذار. + +### پذیرش مرحله 3 + +- [x] `docker compose` از داخل `web/infra/docker` بالا بیاید. +- [x] API health endpoint داشته باشد: `GET /health`. +- [x] frontend صفحه dashboard خالی اما قابل مشاهده داشته باشد. + +--- + +## 4. معماری و قراردادهای مشترک + +### 4.1 اصول معماری + +- [ ] معماری backend باید Clean/Hexagonal باشد. +- [ ] domain layer هیچ dependency به FastAPI، SQLAlchemy، Redis، provider خارجی یا HTTP نداشته باشد. +- [ ] application layer use caseها را اجرا کند. +- [ ] infrastructure layer adapterها را پیاده‌سازی کند. +- [ ] interface/http فقط route و request/response schema داشته باشد. + +### 4.2 Ports الزامی + +این interfaceها را تعریف کن: + +- [ ] `ModelProviderPort` +- [ ] `PaymentProviderPort` +- [ ] `SandboxProviderPort` +- [ ] `GitProviderPort` +- [ ] `DomainRouterPort` +- [ ] `StoragePort` +- [ ] `VectorStorePort` +- [ ] `NotificationPort` +- [ ] `AuditPort` +- [ ] `UsageLedgerPort` + +### 4.3 Adapterهای اولیه + +- [ ] `MockModelProviderAdapter` برای dev/test. +- [ ] `OpenAICompatibleModelAdapter` با base_url و api_key سمت سرور. +- [ ] `LocalMockPaymentAdapter`. +- [ ] `LocalMockSandboxAdapter`. +- [ ] `GiteaGitAdapter`. +- [ ] `CaddyDomainRouterAdapter` یا `ManagedDomainRouterAdapter`. + +### پذیرش مرحله 4 + +- [x] تست واحد نشان دهد domain بدون دیتابیس و FastAPI اجرا می‌شود. +- [x] adapterها با mock قابل جایگزینی باشند. + +--- + +## 5. Identity، Auth و Multi-Tenancy + +### 5.1 مدل‌ها + +جدول‌ها: + +- [ ] `users` + - `id` + - `email` + - `password_hash` + - `name` + - `avatar_url` + - `is_platform_owner` + - `created_at` + - `updated_at` +- [ ] `organizations` + - `id` + - `name` + - `slug` + - `owner_user_id` + - `created_at` +- [ ] `memberships` + - `id` + - `organization_id` + - `user_id` + - `role` + - `status` + - `created_at` +- [ ] `invites` + - `id` + - `organization_id` + - `email` + - `role` + - `token_hash` + - `expires_at` + - `accepted_at` + +### 5.2 APIها + +- [ ] `POST /auth/signup` +- [ ] `POST /auth/login` +- [ ] `POST /auth/logout` +- [ ] `GET /auth/me` +- [ ] `POST /organizations` +- [ ] `GET /organizations` +- [ ] `GET /organizations/{org_id}` +- [ ] `POST /organizations/{org_id}/invites` +- [ ] `GET /organizations/{org_id}/members` +- [ ] `PATCH /organizations/{org_id}/members/{member_id}` + +### 5.3 قوانین امنیتی + +- [ ] هر request باید user فعلی را resolve کند. +- [ ] هر endpoint سازمانی باید membership را چک کند. +- [ ] roleهای اولیه: + - `owner` + - `admin` + - `manager` + - `member` + - `viewer` +- [ ] platform owner فقط برای admin console دسترسی بالاتر دارد؛ نباید خودکار وارد داده خصوصی سازمان شود مگر برای support با audit. + +### 5.4 UIها + +- [ ] signup/login فارسی. +- [ ] onboarding ساخت اولین سازمان. +- [ ] organization switcher. +- [ ] صفحه members و invite. + +### پذیرش مرحله 5 + +- [x] کاربر جدید ثبت‌نام کند. +- [x] سازمان بسازد. +- [x] کاربر دوم را invite کند. +- [x] کاربر بدون membership نتواند داده سازمان را ببیند. +- [x] تست API برای tenant isolation نوشته شود. + +--- + +## 6. Admin Console برای مالک سامانه + +### 6.1 AI Provider Management + +جدول‌ها: + +- [ ] `ai_providers` + - `id` + - `name` + - `type` + - `base_url` + - `is_active` + - `created_at` +- [ ] `ai_model_credentials` + - `id` + - `provider_id` + - `name` + - `encrypted_api_key` + - `is_active` + - `created_at` +- [ ] `ai_models` + - `id` + - `provider_id` + - `internal_name` + - `display_name_for_admin` + - `capabilities` + - `input_price_per_1m` + - `output_price_per_1m` + - `is_active` + +نکته: + +- [ ] کاربر عادی هیچ‌وقت `internal_name`, `provider`, `base_url`, `api_key` یا قیمت خام provider را نبیند. + +### 6.2 Routing Policy + +جدول: + +- [ ] `routing_policies` + - `id` + - `name` + - `description` + - `mode`: `fast`, `cheap`, `balanced`, `reasoning`, `vision`, `coding` + - `fallback_policy_json` + - `budget_limits_json` + - `is_default` + +### 6.3 Rate Card + +جدول: + +- [ ] `rate_cards` + - `id` + - `name` + - `unit` + - `customer_price` + - `internal_cost_estimate` + - `is_active` + +واحدها: + +- [ ] `model_input_tokens` +- [ ] `model_output_tokens` +- [ ] `sandbox_cpu_second` +- [ ] `sandbox_memory_gb_second` +- [ ] `storage_gb_day` +- [ ] `tool_call` +- [ ] `premium_employee_run` + +### 6.4 UI admin + +- [ ] `/admin/providers` +- [ ] `/admin/models` +- [ ] `/admin/routing` +- [ ] `/admin/rate-cards` +- [ ] `/admin/organizations` +- [ ] `/admin/usage` + +### پذیرش مرحله 6 + +- [x] platform owner بتواند provider بسازد. +- [x] platform owner بتواند model بسازد. +- [x] platform owner بتواند routing policy پیش‌فرض بسازد. +- [x] user عادی نتواند هیچ اطلاعات provider/model خام ببیند. + +--- + +## 7. Projects، Git و Repo برای همه کاربران + +این بخش بسیار مهم است چون سیستم فعلی Git دارد و در نسخه وب همه سازمان‌ها/کاربران باید بتوانند از Git استفاده کنند. + +### 7.1 مدل پروژه + +جدول‌ها: + +- [ ] `projects` + - `id` + - `organization_id` + - `name` + - `slug` + - `description` + - `created_by_user_id` + - `default_branch` + - `created_at` +- [ ] `project_repositories` + - `id` + - `project_id` + - `provider`: `gitea` + - `owner` + - `repo_name` + - `clone_url` + - `web_url` + - `default_branch` + - `created_at` +- [ ] `project_git_credentials` + - `id` + - `project_id` + - `username` + - `encrypted_password` + - `created_at` + +### 7.2 GitProviderPort + +تعریف کن: + +```python +class GitProviderPort: + async def ensure_project_repo(project) -> RepoInfo: ... + async def create_branch(project_id, branch_name, from_branch) -> BranchInfo: ... + async def commit_files(project_id, branch_name, files, message) -> CommitInfo: ... + async def open_pull_request(project_id, head, base, title, body) -> PullRequestInfo: ... + async def merge_pull_request(project_id, pr_number) -> MergeResult: ... + async def get_repo_status(project_id) -> RepoStatus: ... +``` + +### 7.3 Gitea adapter + +- [ ] adapter جدید داخل `web/apps/api/app/infrastructure/git/gitea_adapter.py` بساز. +- [ ] از `git_manager.py` و `gitea_api.py` فقط به عنوان reference استفاده کن. +- [ ] admin token فقط سمت backend باشد. +- [ ] repoها private باشند. +- [ ] نام repo collision-free باشد: بهتر است `orgslug-projectslug-shortid`. +- [ ] credential هر پروژه encrypted ذخیره شود. +- [ ] token/password هیچ‌وقت به frontend خام داده نشود مگر برای clone URL موقت و فقط اگر واقعاً لازم شد. + +### 7.4 APIها + +- [ ] `POST /organizations/{org_id}/projects` +- [ ] `GET /organizations/{org_id}/projects` +- [ ] `GET /organizations/{org_id}/projects/{project_id}` +- [ ] `POST /organizations/{org_id}/projects/{project_id}/repo/ensure` +- [ ] `GET /organizations/{org_id}/projects/{project_id}/repo` +- [ ] `POST /organizations/{org_id}/projects/{project_id}/repo/branches` +- [ ] `POST /organizations/{org_id}/projects/{project_id}/repo/pull-requests` + +### 7.5 UIها + +- [ ] صفحه projects. +- [ ] ساخت project. +- [ ] نمایش repo status. +- [ ] دکمه ساخت repo. +- [ ] لینک باز کردن Gitea web_url. +- [ ] نمایش branchها و PRهای باز. + +### پذیرش مرحله 7 + +- [x] هر سازمان بتواند پروژه بسازد. +- [x] هر پروژه بتواند repo خصوصی Gitea بگیرد. +- [x] کاربر عضو سازمان A نتواند repo سازمان B را ببیند یا ایجاد کند. +- [x] platform owner بتواند همه repoها را در admin view ببیند. +- [x] هیچ token در response خام برنگردد. + +--- + +## 8. Domain Routing و Preview Domain برای همه پروژه‌ها + +این بخش هم حیاتی است چون سیستم فعلی route دامنه تست دارد و نسخه وب باید آن را عمومی، امن و ساده کند. + +### 8.1 مدل‌ها + +جدول‌ها: + +- [ ] `preview_ports` + - `id` + - `organization_id` + - `project_id` + - `service_name` + - `port` + - `status` + - `created_at` +- [ ] `preview_domains` + - `id` + - `organization_id` + - `project_id` + - `host` + - `port` + - `status`: `active`, `disabled`, `failed` + - `protected` + - `created_by_user_id` + - `created_at` + +### 8.2 DomainRouterPort + +تعریف کن: + +```python +class DomainRouterPort: + async def allocate_port(org_id, project_id, service_name) -> int: ... + async def claim_preview_domain(org_id, project_id, requested_subdomain, port) -> PreviewDomain: ... + async def configure_reverse_proxy(host, port) -> ReverseProxyResult: ... + async def remove_preview_domain(domain_id) -> None: ... + async def list_project_domains(project_id) -> list[PreviewDomain]: ... +``` + +### 8.3 قوانین امنیتی دامنه + +- [ ] فقط subdomainهای تک‌label زیر `msa.artacloud.ir` مجاز باشند مگر بعداً custom domain رسمی اضافه شود. +- [ ] label فقط شامل `a-z`, `0-9`, `-` باشد. +- [ ] label بین 3 تا 40 کاراکتر باشد. +- [ ] host collision ممنوع است. +- [ ] هر host دقیقاً یک owner داشته باشد. +- [ ] هیچ سازمانی نتواند دامنه سازمان دیگر را حذف یا hijack کند. +- [ ] port فقط از range امن داده شود. +- [ ] target reverse proxy فقط localhost/127.0.0.1 باشد. +- [ ] قبل از reload، Caddy config validate شود. +- [ ] protected domains فقط توسط platform owner قابل تغییر باشند. + +### 8.4 Port allocation + +از منطق فعلی reference بگیر: + +- [ ] range پیش‌فرض: `8080-8999` +- [ ] protected ports را رزرو کن. +- [ ] اگر snapshot listening ports ممکن نبود، port allocate نکن. +- [ ] در concurrent allocation، retry داشته باش. + +### 8.5 APIها + +- [ ] `POST /organizations/{org_id}/projects/{project_id}/preview/ports` +- [ ] `POST /organizations/{org_id}/projects/{project_id}/preview/domains` +- [ ] `GET /organizations/{org_id}/projects/{project_id}/preview/domains` +- [ ] `DELETE /organizations/{org_id}/projects/{project_id}/preview/domains/{domain_id}` + +### 8.6 UIها + +- [ ] در صفحه project یک tab به نام `پیش‌نمایش و دامنه تست` بساز. +- [ ] دکمه `ساخت دامنه تست`. +- [ ] input برای subdomain پیشنهادی. +- [ ] نمایش URL نهایی مثل `https://my-app.msa.artacloud.ir`. +- [ ] وضعیت active/failed. +- [ ] دکمه حذف با confirmation. + +### پذیرش مرحله 8 + +- [x] کاربر عضو سازمان بتواند برای project خودش دامنه تست بسازد. +- [x] دامنه روی Caddy route شود. +- [x] دامنه تکراری خطای واضح بدهد. +- [x] کاربر نتواند دامنه پروژه دیگر را حذف کند. +- [x] route به protected port ممنوع باشد. +- [x] همه عملیات audit شوند. + +--- + +## 9. Sandbox Gateway و محیط ایزوله + +### 9.1 اصل مهم + +- [ ] هیچ کدی از کاربر یا AI worker روی host اصلی اجرا نشود. +- [ ] Sandbox Gateway تنها مسیر اجرای command، build، test، preview و app runtime باشد. + +### 9.2 مدل‌ها + +- [ ] `sandbox_templates` +- [ ] `workspaces` +- [ ] `sandbox_sessions` +- [ ] `command_runs` +- [ ] `snapshots` +- [ ] `preview_urls` +- [ ] `sandbox_resource_limits` + +فیلدهای مهم `workspaces`: + +- `id` +- `organization_id` +- `project_id` +- `template_id` +- `provider` +- `external_workspace_id` +- `status` +- `created_at` + +فیلدهای مهم `sandbox_sessions`: + +- `id` +- `workspace_id` +- `external_session_id` +- `status` +- `started_at` +- `stopped_at` +- `last_active_at` + +### 9.3 SandboxProviderPort + +```python +class SandboxProviderPort: + async def create_workspace(project_id, template_id) -> WorkspaceInfo: ... + async def start_session(workspace_id) -> SessionInfo: ... + async def run_command(session_id, command, timeout_seconds, env=None) -> CommandResult: ... + async def read_file(session_id, path) -> str: ... + async def write_file(session_id, path, content) -> None: ... + async def snapshot(session_id, name) -> SnapshotInfo: ... + async def pause(session_id) -> None: ... + async def resume(session_id) -> SessionInfo: ... + async def destroy(session_id) -> None: ... + async def get_preview_url(session_id, port) -> str: ... +``` + +### 9.4 MVP adapter + +- [ ] اول `LocalMockSandboxAdapter` بساز که command واقعی اجرا نکند، فقط fake result بدهد. +- [ ] سپس adapter واقعی برای Daytona یا E2B بساز؛ اگر credential نداریم، adapter را feature-flag کن. +- [ ] انتخاب provider از env باشد: + - `SANDBOX_PROVIDER=mock|daytona|e2b` + +### 9.5 محدودیت‌ها + +- [ ] timeout برای هر command الزامی. +- [ ] max output size الزامی. +- [ ] max CPU/RAM/DISK در policy ذخیره شود. +- [ ] egress network در طراحی deny-by-default باشد، حتی اگر در MVP mock است. +- [ ] secretها فقط از Secret Broker تزریق شوند. + +### 9.6 اتصال به preview domain + +- [ ] وقتی sandbox app روی port داخلی اجرا شد، backend بتواند از DomainRouterPort دامنه تست بگیرد. +- [ ] mapping باید به session/project وصل باشد. +- [ ] اگر sandbox stop شد، UI باید هشدار بدهد که preview ممکن است offline باشد. + +### پذیرش مرحله 9 + +- [x] project بتواند workspace sandbox بسازد. +- [x] command mock یا واقعی اجرا شود. +- [x] command result در UI دیده شود. +- [x] usage event برای sandbox runtime/command ثبت شود. +- [x] هیچ مسیر API برای اجرای مستقیم shell host وجود نداشته باشد. + +--- + +## 10. AI Workforce و کارمندان هوش مصنوعی + +### 10.1 مدل‌ها + +- [ ] `agent_templates` + - `id` + - `name` + - `role_key` + - `description` + - `default_prompt` + - `default_tools_json` + - `default_routing_policy_id` + - `is_system` +- [ ] `employees` + - `id` + - `organization_id` + - `project_id` nullable + - `template_id` + - `name` + - `role_key` + - `status` + - `routing_policy_id` + - `budget_limit_json` + - `created_at` +- [ ] `prompt_versions` + - `id` + - `employee_id` + - `version` + - `content` + - `created_by_user_id` + - `created_at` +- [ ] `employee_tool_permissions` + - `employee_id` + - `tool_key` + - `allowed` + - `policy_json` + +### 10.2 templateهای اولیه + +بساز: + +- [ ] مدیر سازمان / Manager +- [ ] تحلیل‌گر و معمار / Planner +- [ ] برنامه‌نویس / Developer +- [ ] بازبین کد / Reviewer +- [ ] تستر / Tester +- [ ] محقق / Researcher +- [ ] تولیدکننده محتوا / Content Creator +- [ ] پشتیبان مشتری / Support Agent + +### 10.3 APIها + +- [ ] `GET /organizations/{org_id}/agent-templates` +- [ ] `POST /organizations/{org_id}/employees` +- [ ] `GET /organizations/{org_id}/employees` +- [ ] `GET /organizations/{org_id}/employees/{employee_id}` +- [ ] `PATCH /organizations/{org_id}/employees/{employee_id}` +- [ ] `POST /organizations/{org_id}/employees/{employee_id}/prompt-versions` + +### 10.4 UIها + +- [ ] صفحه `AI Workforce`. +- [ ] کارت هر کارمند: + - نام + - نقش + - وضعیت + - taskهای فعال + - مصرف امروز + - دکمه تنظیمات +- [ ] wizard ساخت کارمند از template. +- [ ] صفحه جزئیات کارمند. + +### پذیرش مرحله 10 + +- [x] سازمان بتواند کارمند AI بسازد. +- [x] کارمند به routing policy وصل باشد اما user مدل خام نبیند. +- [x] prompt version ثبت شود. +- [x] فقط admin/manager بتواند کارمند را تغییر دهد. + +--- + +## 11. Manager Chat، Thread، Task و Run Engine + +### 11.1 مدل‌ها + +- [ ] `threads` +- [ ] `messages` +- [ ] `tasks` +- [ ] `runs` +- [ ] `run_steps` +- [ ] `tool_calls` +- [ ] `artifacts` + +وضعیت‌های task: + +- `new` +- `triaged` +- `waiting_approval` +- `running` +- `done` +- `failed` +- `cancelled` + +وضعیت‌های run: + +- `queued` +- `planning` +- `waiting_approval` +- `running` +- `tool_calling` +- `sandbox_running` +- `completed` +- `failed` +- `cancelled` +- `timed_out` + +### 11.2 APIها + +- [ ] `POST /organizations/{org_id}/threads` +- [ ] `GET /organizations/{org_id}/threads` +- [ ] `GET /organizations/{org_id}/threads/{thread_id}` +- [ ] `POST /organizations/{org_id}/threads/{thread_id}/messages` +- [ ] `POST /organizations/{org_id}/tasks` +- [ ] `GET /organizations/{org_id}/tasks` +- [ ] `GET /organizations/{org_id}/tasks/{task_id}` +- [ ] `POST /organizations/{org_id}/tasks/{task_id}/runs` +- [ ] `GET /organizations/{org_id}/runs/{run_id}` +- [ ] `POST /organizations/{org_id}/runs/{run_id}/cancel` + +### 11.3 اجرای AI + +- [ ] برای MVP، Manager Chat با `MockModelProviderAdapter` پاسخ قابل پیش‌بینی بدهد. +- [ ] سپس `OpenAICompatibleModelAdapter` وصل شود. +- [ ] model routing فقط سمت backend انجام شود. +- [ ] هر model call باید usage event بسازد. +- [ ] tool callها در `tool_calls` ثبت شوند. +- [ ] run_steps timeline بسازد. + +### 11.4 UI Manager + +- [ ] صفحه `/manager` split-view داشته باشد: + - ستون chat + - ستون task/artifacts + - ستون کوچک run timeline یا logs +- [ ] پیام‌ها realtime یا polling سبک داشته باشند. +- [ ] اگر task ساخته شد، card task نشان بده. +- [ ] اگر run در حال اجراست، status واضح نشان بده. + +### پذیرش مرحله 11 + +- [x] کاربر با Manager Chat پیام بفرستد. +- [x] Manager بتواند task بسازد یا پیشنهاد task بدهد. +- [x] task run شود. +- [x] run_steps و usage_events ثبت شود. +- [x] UI خروجی و artifact را نشان دهد. + +--- + +## 12. Tools و دسترسی کارمندها + +### 12.1 tool registry + +Toolهای اولیه: + +- [ ] `git.ensure_repo` +- [ ] `git.create_branch` +- [ ] `git.open_pr` +- [ ] `domain.create_preview` +- [ ] `sandbox.run_command` +- [ ] `sandbox.read_file` +- [ ] `sandbox.write_file` +- [ ] `knowledge.search` +- [ ] `artifact.create` + +### 12.2 قوانین toolها + +- [ ] هر tool باید permission check داشته باشد. +- [ ] هر tool call باید audit و trace شود. +- [ ] toolهای خطرناک approval بخواهند: + - حذف repo + - حذف دامنه + - merge PR + - اجرای command با دسترسی بالا + - حذف فایل + - destroy sandbox + +### 12.3 UI approvals + +- [ ] صفحه task باید actionهای منتظر approval را نشان دهد. +- [ ] approve/reject با توضیح ثبت شود. + +### پذیرش مرحله 12 + +- [x] کارمند بدون permission نتواند tool را اجرا کند. +- [x] approval flow برای tool خطرناک کار کند. +- [x] audit log کامل ثبت شود. + +--- + +## 13. Billing و Usage Ledger + +### 13.1 مدل‌ها + +- [ ] `plans` +- [ ] `subscriptions` +- [ ] `credit_wallets` +- [ ] `usage_events` +- [ ] `invoices` +- [ ] `payments` + +فیلدهای `usage_events`: + +- `id` +- `organization_id` +- `project_id` nullable +- `user_id` nullable +- `employee_id` nullable +- `run_id` nullable +- `unit` +- `quantity` +- `unit_price` +- `internal_cost_estimate` +- `total_price` +- `metadata_json` +- `created_at` + +### 13.2 قوانین billing + +- [ ] هر مصرف باید اول در ledger داخلی ثبت شود. +- [ ] Stripe یا payment provider فقط adapter است؛ منبع حقیقت usage ledger داخلی است. +- [ ] اگر wallet اعتبار کافی نداشت، run جدید متوقف یا نیازمند پرداخت شود. +- [ ] platform owner بتواند credit دستی اضافه کند. + +### 13.3 APIها + +- [ ] `GET /organizations/{org_id}/billing/summary` +- [ ] `GET /organizations/{org_id}/billing/usage-events` +- [ ] `GET /organizations/{org_id}/billing/invoices` +- [ ] `POST /organizations/{org_id}/billing/add-credit` فقط admin/platform owner طبق policy +- [ ] `POST /organizations/{org_id}/billing/checkout` در صورت اتصال payment واقعی + +### 13.4 UI Billing + +- [ ] موجودی اعتبار. +- [ ] مصرف امروز/ماه. +- [ ] breakdown بر اساس employee/project/unit. +- [ ] invoice list. +- [ ] هشدار اعتبار کم. + +### پذیرش مرحله 13 + +- [x] هر model call usage بسازد. +- [x] هر sandbox command usage بسازد. +- [x] billing summary درست جمع بزند. +- [x] کاربر عادی فقط billing سازمان خودش را ببیند. + +--- + +## 14. Knowledge Base + +### 14.1 مدل‌ها + +- [ ] `knowledge_sources` +- [ ] `documents` +- [ ] `document_chunks` +- [ ] `embedding_jobs` +- [ ] `retrieval_policies` + +### 14.2 MVP + +- [ ] upload فایل متنی/markdown/pdf اگر ساده بود. +- [ ] extract text. +- [ ] chunk. +- [ ] embedding با adapter قابل mock. +- [ ] search ساده keyword در MVP اگر vector آماده نبود. + +### 14.3 permissions + +- [ ] هر knowledge source به organization وصل باشد. +- [ ] optionally به project وصل باشد. +- [ ] employee فقط knowledge مجاز خودش را ببیند. + +### پذیرش مرحله 14 + +- [x] کاربر فایل knowledge اضافه کند. +- [x] Manager در پاسخ بتواند از knowledge استفاده کند. +- [x] سازمان دیگر به این knowledge دسترسی نداشته باشد. + +--- + +## 15. Observability، Audit و Trace + +### 15.1 مدل‌ها + +- [ ] `audit_events` +- [ ] `traces` +- [ ] `spans` +- [ ] `error_events` +- [ ] `quality_metrics` + +### 15.2 audit الزامی برای + +- [ ] login/logout +- [ ] ساخت/حذف سازمان +- [ ] invite و تغییر role +- [ ] ساخت/تغییر employee +- [ ] ساخت/حذف repo +- [ ] ساخت/حذف preview domain +- [ ] sandbox command +- [ ] approval/rejection +- [ ] billing credit/payment +- [ ] تغییر provider/model/routing + +### 15.3 UI + +- [ ] صفحه audit در settings سازمان. +- [ ] صفحه traces در admin یا task detail. +- [ ] timeline run در task detail. + +### پذیرش مرحله 15 + +- [x] هر عملیات حساس audit شود. +- [x] trace یک run از ابتدا تا انتها قابل مشاهده باشد. + +--- + +## 16. UI/UX کامل + +### 16.1 Design System + +- [ ] رنگ‌بندی dark حرفه‌ای. +- [ ] RTL کامل. +- [ ] فونت مناسب فارسی. +- [ ] componentها: + - Button + - Card + - Badge + - Modal + - Drawer + - Table + - Tabs + - Toast + - EmptyState + - LoadingState + - ErrorState + - ConfirmDialog + - Timeline + - UsageBar + - EmployeeCard + - TaskCard + - ArtifactViewer + +### 16.2 Layout اصلی + +- [ ] sidebar: + - داشبورد + - مدیر AI + - نیروی کار AI + - پروژه‌ها + - وظایف + - دانش سازمانی + - مصرف و پرداخت + - تنظیمات +- [ ] topbar: + - organization switcher + - command/search + - user menu + - credit badge + +### 16.3 صفحات + +- [ ] Dashboard + - خلاصه مصرف + - taskهای فعال + - کارمندهای فعال + - خطاهای اخیر + - preview domains +- [ ] Workforce +- [ ] Manager Chat +- [ ] Tasks +- [ ] Project detail + - Overview + - Git + - Sandbox + - Preview Domain + - Artifacts +- [ ] Billing +- [ ] Knowledge +- [ ] Settings +- [ ] Admin Console + +### پذیرش مرحله 16 + +- [x] UI در desktop خوب باشد. +- [x] RTL کامل باشد. +- [x] stateهای empty/loading/error وجود داشته باشد. +- [x] user عادی هیچ عبارت provider/model خام نبیند. + +--- + +## 17. Seed Data و Dev Experience + +### 17.1 seed + +- [ ] یک platform owner بساز. +- [ ] یک user عادی بساز. +- [ ] یک organization نمونه بساز. +- [ ] چند employee template بساز. +- [ ] routing policyهای نمونه بساز. +- [ ] rate card نمونه بساز. +- [ ] provider mock بساز. + +### 17.2 dev commands + +- [ ] `pnpm dev` frontend و backend را بالا بیاورد یا حداقل راهنمای واضح بدهد. +- [ ] `pnpm test` تست‌ها را اجرا کند. +- [ ] `pnpm db:migrate` migrationها را اجرا کند. +- [ ] `pnpm db:seed` داده seed بسازد. + +### پذیرش مرحله 17 + +- [x] توسعه‌دهنده جدید با README بتواند پروژه را بالا بیاورد. +- [x] seed loginها در README نوشته شده باشند، بدون secret واقعی production. + +--- + +## 18. تست‌ها + +### 18.1 Backend tests + +- [ ] auth signup/login. +- [ ] organization isolation. +- [ ] role permission. +- [ ] provider hidden from user. +- [ ] project creation. +- [ ] Gitea adapter mock. +- [ ] domain allocation collision. +- [ ] protected port/domain rejection. +- [ ] sandbox command mock. +- [ ] usage ledger aggregation. +- [ ] audit event creation. +- [ ] task/run lifecycle. + +### 18.2 Frontend tests + +- [ ] صفحات اصلی render شوند. +- [ ] organization switcher کار کند. +- [ ] dashboard empty state داشته باشد. +- [ ] manager chat message ارسال کند. +- [ ] project preview domain form validation داشته باشد. + +### 18.3 E2E حداقلی + +سناریو: + +- [ ] signup +- [ ] create organization +- [ ] create project +- [ ] ensure repo +- [ ] create preview domain +- [ ] create employee +- [ ] send manager message +- [ ] create task +- [ ] run task +- [ ] see usage summary + +### پذیرش مرحله 18 + +- [x] تست‌ها در CI/local با یک دستور اجرا شوند. +- [x] تست multi-tenancy حتماً وجود داشته باشد. + +--- + +## 19. امنیت + +### 19.1 Secret Management + +- [ ] API keys encrypted at rest. +- [ ] passwordها hash با argon2 یا bcrypt. +- [ ] JWT secret از env. +- [ ] frontend هیچ secret ندارد. + +### 19.2 Access Control + +- [ ] middleware مرکزی برای auth. +- [ ] dependency مرکزی برای org membership. +- [ ] policy object برای role/permission. +- [ ] تست منفی برای unauthorized/forbidden. + +### 19.3 Sandbox Security + +- [ ] host shell مستقیم ممنوع. +- [ ] command timeout. +- [ ] output limit. +- [ ] egress policy طراحی شود. +- [ ] audit برای command. + +### 19.4 Domain/Git Security + +- [ ] host injection ممنوع. +- [ ] Caddy target SSRF ممنوع. +- [ ] protected ports ممنوع. +- [ ] repo private. +- [ ] token never returned. + +### پذیرش مرحله 19 + +- [x] security checklist داخل `web/docs/security-checklist.md` کامل شود. +- [x] تست‌های امنیتی اصلی سبز باشند. + +--- + +## 20. Performance + +### 20.1 Frontend + +- [ ] loading skeleton. +- [ ] pagination برای tables. +- [ ] caching با TanStack Query یا روش مشابه. +- [ ] bundle بی‌دلیل سنگین نشود. + +### 20.2 Backend + +- [ ] pagination در list endpoints. +- [ ] index روی `organization_id`, `project_id`, `created_at`. +- [ ] queryهای dashboard aggregate باشند. +- [ ] long-running taskها background worker بروند. + +### 20.3 AI/Sandbox + +- [ ] runها async باشند. +- [ ] timeout و retry کنترل‌شده. +- [ ] cancellation ممکن باشد. + +### پذیرش مرحله 20 + +- [x] dashboard با seed data معمولی سریع load شود. +- [x] API listها pagination داشته باشند. + +--- + +## 21. Migration از ذهنیت فعلی به نسخه وب + +این migration دیتای production فعلی نیست؛ منظور تبدیل مفاهیم است. + +### 21.1 Mapping مفاهیم + +- Telegram user/chat_id → User +- allowed/admin user ids → platform owner و memberships +- project workspace → Project + Workspace + Repo +- bot inline menu → Web pages/components +- USER_INPUT_STATES → DB-backed wizard/form state +- AGY/OpenCode model selection → hidden AI Control Plane routing +- org manager/current organization → Organization + AI Manager Employee +- Caddy subdomain → PreviewDomain service +- Gitea repo → ProjectRepository service + +### 21.2 ممنوعیت‌ها + +- [ ] `chat_id` در schema جدید استفاده نشود. +- [ ] Telegram auth در نسخه جدید استفاده نشود. +- [ ] callback_data در workflow جدید استفاده نشود. + +--- + +## 22. ترتیب اجرای پیشنهادی برای DeepSeek + +اگر عامل کدنویس نمی‌داند از کجا شروع کند، دقیقاً این ترتیب را اجرا کند: + +1. [x] ساخت ساختار پوشه و README. +2. [x] bootstrap frontend و backend. +3. [x] docker compose برای postgres/redis. +4. [x] health endpoint و صفحه dashboard خالی. +5. [x] auth و organization. +6. [x] membership و RBAC. +7. [x] admin provider/model/routing/rate-card با mock. +8. [x] projects. +9. [x] GitProviderPort و Gitea adapter mock/real. +10. [x] DomainRouterPort و preview domain mock/real. +11. [x] Sandbox Gateway mock. +12. [x] AI Workforce templates و employees. +13. [x] Manager Chat mock. +14. [x] Task/Run engine. +15. [x] Usage ledger. +16. [x] Billing UI. +17. [x] Audit/Trace. +18. [x] Sandbox real adapter اگر credential موجود بود. +19. [x] UI polish. +20. [x] تست E2E. + +--- + +## 23. Definition of Done نهایی + +قبل از تحویل نهایی، این چک‌لیست باید کامل باشد: + +- [x] `web/README.md` کامل است. +- [x] `web/docs/architecture/overview.md` کامل است. +- [x] `web/docs/security-checklist.md` کامل است. +- [x] frontend بالا می‌آید. +- [x] backend بالا می‌آید. +- [x] database migration اجرا می‌شود. +- [x] seed اجرا می‌شود. +- [x] کاربر ثبت‌نام می‌کند. +- [x] سازمان ساخته می‌شود. +- [x] عضویت و role کار می‌کند. +- [x] admin console provider/model/routing/rate-card دارد. +- [x] user مدل/provider خام نمی‌بیند. +- [x] پروژه ساخته می‌شود. +- [x] repo ساخته یا mock می‌شود. +- [x] preview domain ساخته یا mock می‌شود. +- [x] sandbox workspace ساخته یا mock می‌شود. +- [x] کارمند AI ساخته می‌شود. +- [x] Manager Chat کار می‌کند. +- [x] task ساخته و run می‌شود. +- [x] usage event ثبت می‌شود. +- [x] billing summary نشان داده می‌شود. +- [x] audit log نشان داده می‌شود. +- [x] تست multi-tenancy وجود دارد و سبز است. +- [x] تست domain security وجود دارد و سبز است. +- [x] تست git provider وجود دارد و سبز است. +- [x] تست usage ledger وجود دارد و سبز است. +- [x] `pnpm lint` موفق است. +- [x] `pnpm test` موفق است. +- [x] `pnpm build` موفق است. + +--- + +## 24. یادداشت مهم درباره «کامل انجام بده» + +اگر در زمان اجرا credential واقعی برای Gitea، Caddy، Daytona، E2B یا payment provider وجود نداشت: + +- [ ] feature را حذف نکن. +- [ ] interface و UI و مدل دیتابیس را کامل بساز. +- [ ] adapter mock قابل تست بساز. +- [ ] adapter واقعی را پشت env flag آماده کن. +- [ ] در README دقیق بنویس برای فعال‌سازی واقعی چه envهایی لازم است. + +یعنی خروجی باید از نظر محصول و معماری کامل باشد، حتی اگر بعضی providerهای خارجی در محیط dev با mock اجرا شوند. + +--- + +## 25. envهای پیشنهادی + +در `web/.env.example` این موارد را قرار بده: + +```env +APP_ENV=development +APP_URL=http://localhost:3000 +API_URL=http://localhost:8000 + +DATABASE_URL=postgresql+psycopg://agy:agy@localhost:5432/agy_web +REDIS_URL=redis://localhost:6379/0 + +JWT_SECRET=change-me +ENCRYPTION_KEY=change-me-32-bytes + +AI_DEFAULT_PROVIDER=mock +OPENAI_COMPATIBLE_BASE_URL= +OPENAI_COMPATIBLE_API_KEY= + +GIT_PROVIDER=mock +GITEA_URL= +GITEA_INTERNAL_URL= +GITEA_ADMIN_TOKEN= + +DOMAIN_ROUTER=mock +PREVIEW_ROOT_DOMAIN=msa.artacloud.ir +PREVIEW_PORT_START=8080 +PREVIEW_PORT_END=8999 +CADDYFILE_PATH=/etc/caddy/Caddyfile + +SANDBOX_PROVIDER=mock +DAYTONA_API_KEY= +E2B_API_KEY= + +PAYMENT_PROVIDER=mock +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +``` + +--- + +## 26. کیفیت خروجی مورد انتظار + +خروجی نهایی نباید فقط چند صفحه UI نمایشی باشد. باید حداقل این‌ها واقعاً کار کنند: + +- [ ] auth واقعی با DB. +- [ ] organization واقعی با tenant isolation. +- [ ] project واقعی. +- [ ] employee واقعی. +- [ ] thread/message/task/run واقعی. +- [ ] usage ledger واقعی. +- [ ] audit واقعی. +- [ ] providerها حداقل با mock کامل. +- [ ] Git و Domain و Sandbox حداقل با mock کامل و adapter واقعی آماده. + +--- + +## 27. پیام آخر برای عامل اجراکننده + +این پروژه را کوچک اما تمیز بساز. +اول correctness و معماری، بعد زیبایی. +هیچ چیزی را به تلگرام گره نزن. +همه چیز را سازمان‌محور بساز. +مدل‌ها و providerها را از کاربر پنهان کن. +Git و دامنه تست را برای همه پروژه‌ها قابل استفاده کن. +اجرای کد را فقط از Sandbox Gateway عبور بده. +هر مصرفی را در usage ledger ثبت کن. +هر عملیات حساس را audit کن. +در پایان build/test/lint را اجرا کن و خطاها را رفع کن. diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..b056465 --- /dev/null +++ b/apps/api/alembic.ini @@ -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 diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py new file mode 100644 index 0000000..ecdc678 --- /dev/null +++ b/apps/api/alembic/env.py @@ -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() diff --git a/apps/api/alembic/script.py.mako b/apps/api/alembic/script.py.mako new file mode 100644 index 0000000..36173ea --- /dev/null +++ b/apps/api/alembic/script.py.mako @@ -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"} diff --git a/apps/api/alembic/versions/c7ecdf7882fe_initial_schema.py b/apps/api/alembic/versions/c7ecdf7882fe_initial_schema.py new file mode 100644 index 0000000..d4ec034 --- /dev/null +++ b/apps/api/alembic/versions/c7ecdf7882fe_initial_schema.py @@ -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 ### diff --git a/apps/api/alembic/versions/f5ba57af2906_extend_error_events_for_the_error_.py b/apps/api/alembic/versions/f5ba57af2906_extend_error_events_for_the_error_.py new file mode 100644 index 0000000..24c3334 --- /dev/null +++ b/apps/api/alembic/versions/f5ba57af2906_extend_error_events_for_the_error_.py @@ -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) diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..275807f --- /dev/null +++ b/apps/api/app/__init__.py @@ -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" diff --git a/apps/api/app/application/__init__.py b/apps/api/app/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/application/container.py b/apps/api/app/application/container.py new file mode 100644 index 0000000..ab3af4c --- /dev/null +++ b/apps/api/app/application/container.py @@ -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", +] diff --git a/apps/api/app/application/ports/__init__.py b/apps/api/app/application/ports/__init__.py new file mode 100644 index 0000000..d7689a9 --- /dev/null +++ b/apps/api/app/application/ports/__init__.py @@ -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", +] diff --git a/apps/api/app/application/ports/audit.py b/apps/api/app/application/ports/audit.py new file mode 100644 index 0000000..f571e60 --- /dev/null +++ b/apps/api/app/application/ports/audit.py @@ -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: ... diff --git a/apps/api/app/application/ports/domain_router.py b/apps/api/app/application/ports/domain_router.py new file mode 100644 index 0000000..7c8cba4 --- /dev/null +++ b/apps/api/app/application/ports/domain_router.py @@ -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]: ... diff --git a/apps/api/app/application/ports/git_provider.py b/apps/api/app/application/ports/git_provider.py new file mode 100644 index 0000000..5ef857a --- /dev/null +++ b/apps/api/app/application/ports/git_provider.py @@ -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: ... diff --git a/apps/api/app/application/ports/model_provider.py b/apps/api/app/application/ports/model_provider.py new file mode 100644 index 0000000..0a19d87 --- /dev/null +++ b/apps/api/app/application/ports/model_provider.py @@ -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: + ... diff --git a/apps/api/app/application/ports/payment_provider.py b/apps/api/app/application/ports/payment_provider.py new file mode 100644 index 0000000..c0fdc99 --- /dev/null +++ b/apps/api/app/application/ports/payment_provider.py @@ -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: ... diff --git a/apps/api/app/application/ports/sandbox_provider.py b/apps/api/app/application/ports/sandbox_provider.py new file mode 100644 index 0000000..f3a0c73 --- /dev/null +++ b/apps/api/app/application/ports/sandbox_provider.py @@ -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: ... diff --git a/apps/api/app/application/ports/storage.py b/apps/api/app/application/ports/storage.py new file mode 100644 index 0000000..fe8eb31 --- /dev/null +++ b/apps/api/app/application/ports/storage.py @@ -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: ... diff --git a/apps/api/app/application/services/__init__.py b/apps/api/app/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/application/services/audit_service.py b/apps/api/app/application/services/audit_service.py new file mode 100644 index 0000000..40ebff4 --- /dev/null +++ b/apps/api/app/application/services/audit_service.py @@ -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) diff --git a/apps/api/app/application/services/billing_service.py b/apps/api/app/application/services/billing_service.py new file mode 100644 index 0000000..6700eb7 --- /dev/null +++ b/apps/api/app/application/services/billing_service.py @@ -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 diff --git a/apps/api/app/application/services/knowledge_service.py b/apps/api/app/application/services/knowledge_service.py new file mode 100644 index 0000000..0e99166 --- /dev/null +++ b/apps/api/app/application/services/knowledge_service.py @@ -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 {}) + ) + ) diff --git a/apps/api/app/application/services/project_service.py b/apps/api/app/application/services/project_service.py new file mode 100644 index 0000000..88e4288 --- /dev/null +++ b/apps/api/app/application/services/project_service.py @@ -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 diff --git a/apps/api/app/application/services/sandbox_service.py b/apps/api/app/application/services/sandbox_service.py new file mode 100644 index 0000000..8e42185 --- /dev/null +++ b/apps/api/app/application/services/sandbox_service.py @@ -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 diff --git a/apps/api/app/application/services/workforce_service.py b/apps/api/app/application/services/workforce_service.py new file mode 100644 index 0000000..0175b0d --- /dev/null +++ b/apps/api/app/application/services/workforce_service.py @@ -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) diff --git a/apps/api/app/core/__init__.py b/apps/api/app/core/__init__.py new file mode 100644 index 0000000..3616147 --- /dev/null +++ b/apps/api/app/core/__init__.py @@ -0,0 +1 @@ +"""Cross-cutting concerns: configuration, database, security, crypto, errors.""" diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py new file mode 100644 index 0000000..e6c1fca --- /dev/null +++ b/apps/api/app/core/config.py @@ -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() diff --git a/apps/api/app/core/crypto.py b/apps/api/app/core/crypto.py new file mode 100644 index 0000000..a5babd0 --- /dev/null +++ b/apps/api/app/core/crypto.py @@ -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 diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py new file mode 100644 index 0000000..2f8143b --- /dev/null +++ b/apps/api/app/core/database.py @@ -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 diff --git a/apps/api/app/core/errors.py b/apps/api/app/core/errors.py new file mode 100644 index 0000000..593f69d --- /dev/null +++ b/apps/api/app/core/errors.py @@ -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" diff --git a/apps/api/app/core/redact.py b/apps/api/app/core/redact.py new file mode 100644 index 0000000..8919573 --- /dev/null +++ b/apps/api/app/core/redact.py @@ -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[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/\s:@]+:[^/\s@]+@"), + r"\g[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 {} diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py new file mode 100644 index 0000000..3fd2cf9 --- /dev/null +++ b/apps/api/app/core/security.py @@ -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() diff --git a/apps/api/app/domain/__init__.py b/apps/api/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/ai_control/__init__.py b/apps/api/app/domain/ai_control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/ai_control/models.py b/apps/api/app/domain/ai_control/models.py new file mode 100644 index 0000000..826c800 --- /dev/null +++ b/apps/api/app/domain/ai_control/models.py @@ -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) diff --git a/apps/api/app/domain/audit/__init__.py b/apps/api/app/domain/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/audit/models.py b/apps/api/app/domain/audit/models.py new file mode 100644 index 0000000..a0c140f --- /dev/null +++ b/apps/api/app/domain/audit/models.py @@ -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) diff --git a/apps/api/app/domain/base.py b/apps/api/app/domain/base.py new file mode 100644 index 0000000..077f140 --- /dev/null +++ b/apps/api/app/domain/base.py @@ -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 diff --git a/apps/api/app/domain/billing/__init__.py b/apps/api/app/domain/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/billing/models.py b/apps/api/app/domain/billing/models.py new file mode 100644 index 0000000..bf7f54f --- /dev/null +++ b/apps/api/app/domain/billing/models.py @@ -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) diff --git a/apps/api/app/domain/identity/__init__.py b/apps/api/app/domain/identity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/identity/models.py b/apps/api/app/domain/identity/models.py new file mode 100644 index 0000000..d07ee8c --- /dev/null +++ b/apps/api/app/domain/identity/models.py @@ -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) diff --git a/apps/api/app/domain/knowledge/__init__.py b/apps/api/app/domain/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/knowledge/models.py b/apps/api/app/domain/knowledge/models.py new file mode 100644 index 0000000..90198b7 --- /dev/null +++ b/apps/api/app/domain/knowledge/models.py @@ -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) diff --git a/apps/api/app/domain/platform/__init__.py b/apps/api/app/domain/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/platform/rules.py b/apps/api/app/domain/platform/rules.py new file mode 100644 index 0000000..47f6d0c --- /dev/null +++ b/apps/api/app/domain/platform/rules.py @@ -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 diff --git a/apps/api/app/domain/projects/__init__.py b/apps/api/app/domain/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/projects/models.py b/apps/api/app/domain/projects/models.py new file mode 100644 index 0000000..f707dcd --- /dev/null +++ b/apps/api/app/domain/projects/models.py @@ -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) diff --git a/apps/api/app/domain/sandbox/__init__.py b/apps/api/app/domain/sandbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/sandbox/models.py b/apps/api/app/domain/sandbox/models.py new file mode 100644 index 0000000..197e1d8 --- /dev/null +++ b/apps/api/app/domain/sandbox/models.py @@ -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) diff --git a/apps/api/app/domain/workforce/__init__.py b/apps/api/app/domain/workforce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain/workforce/models.py b/apps/api/app/domain/workforce/models.py new file mode 100644 index 0000000..d71f224 --- /dev/null +++ b/apps/api/app/domain/workforce/models.py @@ -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 diff --git a/apps/api/app/domain/workforce/tools.py b/apps/api/app/domain/workforce/tools.py new file mode 100644 index 0000000..af0b04c --- /dev/null +++ b/apps/api/app/domain/workforce/tools.py @@ -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 diff --git a/apps/api/app/infrastructure/__init__.py b/apps/api/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/infrastructure/adapters/__init__.py b/apps/api/app/infrastructure/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/infrastructure/adapters/caddy.py b/apps/api/app/infrastructure/adapters/caddy.py new file mode 100644 index 0000000..7aae3a5 --- /dev/null +++ b/apps/api/app/infrastructure/adapters/caddy.py @@ -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\S+)\n(?P.*?)# <<< 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) diff --git a/apps/api/app/infrastructure/adapters/gitea.py b/apps/api/app/infrastructure/adapters/gitea.py new file mode 100644 index 0000000..fd5b70c --- /dev/null +++ b/apps/api/app/infrastructure/adapters/gitea.py @@ -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)}" diff --git a/apps/api/app/infrastructure/adapters/mock.py b/apps/api/app/infrastructure/adapters/mock.py new file mode 100644 index 0000000..a81479b --- /dev/null +++ b/apps/api/app/infrastructure/adapters/mock.py @@ -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] diff --git a/apps/api/app/infrastructure/adapters/openai_compatible.py b/apps/api/app/infrastructure/adapters/openai_compatible.py new file mode 100644 index 0000000..d3d4620 --- /dev/null +++ b/apps/api/app/infrastructure/adapters/openai_compatible.py @@ -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)) diff --git a/apps/api/app/infrastructure/adapters/registry.py b/apps/api/app/infrastructure/adapters/registry.py new file mode 100644 index 0000000..641b0b8 --- /dev/null +++ b/apps/api/app/infrastructure/adapters/registry.py @@ -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() diff --git a/apps/api/app/infrastructure/adapters/sandbox_remote.py b/apps/api/app/infrastructure/adapters/sandbox_remote.py new file mode 100644 index 0000000..ae7977c --- /dev/null +++ b/apps/api/app/infrastructure/adapters/sandbox_remote.py @@ -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" + ) diff --git a/apps/api/app/infrastructure/adapters/stripe_payments.py b/apps/api/app/infrastructure/adapters/stripe_payments.py new file mode 100644 index 0000000..2e6d2be --- /dev/null +++ b/apps/api/app/infrastructure/adapters/stripe_payments.py @@ -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() diff --git a/apps/api/app/infrastructure/db/__init__.py b/apps/api/app/infrastructure/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/infrastructure/db/models.py b/apps/api/app/infrastructure/db/models.py new file mode 100644 index 0000000..17111ac --- /dev/null +++ b/apps/api/app/infrastructure/db/models.py @@ -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() diff --git a/apps/api/app/infrastructure/error_log.py b/apps/api/app/infrastructure/error_log.py new file mode 100644 index 0000000..9505c9a --- /dev/null +++ b/apps/api/app/infrastructure/error_log.py @@ -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", +] diff --git a/apps/api/app/infrastructure/repositories/__init__.py b/apps/api/app/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/infrastructure/repositories/ai_control.py b/apps/api/app/infrastructure/repositories/ai_control.py new file mode 100644 index 0000000..4b84a37 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/ai_control.py @@ -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) diff --git a/apps/api/app/infrastructure/repositories/audit.py b/apps/api/app/infrastructure/repositories/audit.py new file mode 100644 index 0000000..60e19eb --- /dev/null +++ b/apps/api/app/infrastructure/repositories/audit.py @@ -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() + } diff --git a/apps/api/app/infrastructure/repositories/billing.py b/apps/api/app/infrastructure/repositories/billing.py new file mode 100644 index 0000000..f178daa --- /dev/null +++ b/apps/api/app/infrastructure/repositories/billing.py @@ -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) diff --git a/apps/api/app/infrastructure/repositories/identity.py b/apps/api/app/infrastructure/repositories/identity.py new file mode 100644 index 0000000..b8ab950 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/identity.py @@ -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() diff --git a/apps/api/app/infrastructure/repositories/knowledge.py b/apps/api/app/infrastructure/repositories/knowledge.py new file mode 100644 index 0000000..9e24978 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/knowledge.py @@ -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, + ) diff --git a/apps/api/app/infrastructure/repositories/projects.py b/apps/api/app/infrastructure/repositories/projects.py new file mode 100644 index 0000000..a22609a --- /dev/null +++ b/apps/api/app/infrastructure/repositories/projects.py @@ -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, + } diff --git a/apps/api/app/infrastructure/repositories/sandbox.py b/apps/api/app/infrastructure/repositories/sandbox.py new file mode 100644 index 0000000..a06eb31 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/sandbox.py @@ -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", +] diff --git a/apps/api/app/infrastructure/repositories/workforce.py b/apps/api/app/infrastructure/repositories/workforce.py new file mode 100644 index 0000000..12523c5 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/workforce.py @@ -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) diff --git a/apps/api/app/interfaces/__init__.py b/apps/api/app/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/interfaces/http/__init__.py b/apps/api/app/interfaces/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/interfaces/http/deps.py b/apps/api/app/interfaces/http/deps.py new file mode 100644 index 0000000..fdd297c --- /dev/null +++ b/apps/api/app/interfaces/http/deps.py @@ -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) diff --git a/apps/api/app/interfaces/http/errors.py b/apps/api/app/interfaces/http/errors.py new file mode 100644 index 0000000..45d23d0 --- /dev/null +++ b/apps/api/app/interfaces/http/errors.py @@ -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), + ) diff --git a/apps/api/app/interfaces/http/request_context.py b/apps/api/app/interfaces/http/request_context.py new file mode 100644 index 0000000..3440fca --- /dev/null +++ b/apps/api/app/interfaces/http/request_context.py @@ -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", +] diff --git a/apps/api/app/interfaces/http/routers/__init__.py b/apps/api/app/interfaces/http/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/interfaces/http/routers/admin.py b/apps/api/app/interfaces/http/routers/admin.py new file mode 100644 index 0000000..c63df51 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/admin.py @@ -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) diff --git a/apps/api/app/interfaces/http/routers/auth.py b/apps/api/app/interfaces/http/routers/auth.py new file mode 100644 index 0000000..446ba64 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/auth.py @@ -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 diff --git a/apps/api/app/interfaces/http/routers/billing.py b/apps/api/app/interfaces/http/routers/billing.py new file mode 100644 index 0000000..d05f0f2 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/billing.py @@ -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, + ) diff --git a/apps/api/app/interfaces/http/routers/health.py b/apps/api/app/interfaces/http/routers/health.py new file mode 100644 index 0000000..06d9af3 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/health.py @@ -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} diff --git a/apps/api/app/interfaces/http/routers/knowledge.py b/apps/api/app/interfaces/http/routers/knowledge.py new file mode 100644 index 0000000..3eec98f --- /dev/null +++ b/apps/api/app/interfaces/http/routers/knowledge.py @@ -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) + ) diff --git a/apps/api/app/interfaces/http/routers/organizations.py b/apps/api/app/interfaces/http/routers/organizations.py new file mode 100644 index 0000000..3132f1c --- /dev/null +++ b/apps/api/app/interfaces/http/routers/organizations.py @@ -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 + ] diff --git a/apps/api/app/interfaces/http/routers/projects.py b/apps/api/app/interfaces/http/routers/projects.py new file mode 100644 index 0000000..7ecbbee --- /dev/null +++ b/apps/api/app/interfaces/http/routers/projects.py @@ -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, + ) diff --git a/apps/api/app/interfaces/http/routers/sandbox.py b/apps/api/app/interfaces/http/routers/sandbox.py new file mode 100644 index 0000000..21cd8d6 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/sandbox.py @@ -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) diff --git a/apps/api/app/interfaces/http/routers/workforce.py b/apps/api/app/interfaces/http/routers/workforce.py new file mode 100644 index 0000000..507dc44 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/workforce.py @@ -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 + ], + ) diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..83ee93c --- /dev/null +++ b/apps/api/app/main.py @@ -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() diff --git a/apps/api/app/seed.py b/apps/api/app/seed.py new file mode 100644 index 0000000..8f369fc --- /dev/null +++ b/apps/api/app/seed.py @@ -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` 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 diff --git a/apps/api/app/workers/__init__.py b/apps/api/app/workers/__init__.py new file mode 100644 index 0000000..5e58b19 --- /dev/null +++ b/apps/api/app/workers/__init__.py @@ -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", +] diff --git a/apps/api/app/workers/__main__.py b/apps/api/app/workers/__main__.py new file mode 100644 index 0000000..612c02f --- /dev/null +++ b/apps/api/app/workers/__main__.py @@ -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()) diff --git a/apps/api/app/workers/ai_runtime.py b/apps/api/app/workers/ai_runtime.py new file mode 100644 index 0000000..a5e5589 --- /dev/null +++ b/apps/api/app/workers/ai_runtime.py @@ -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"] diff --git a/apps/api/app/workers/base.py b/apps/api/app/workers/base.py new file mode 100644 index 0000000..6229eec --- /dev/null +++ b/apps/api/app/workers/base.py @@ -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", +] diff --git a/apps/api/app/workers/billing.py b/apps/api/app/workers/billing.py new file mode 100644 index 0000000..22895cc --- /dev/null +++ b/apps/api/app/workers/billing.py @@ -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"] diff --git a/apps/api/app/workers/sandbox_gateway.py b/apps/api/app/workers/sandbox_gateway.py new file mode 100644 index 0000000..d6d218e --- /dev/null +++ b/apps/api/app/workers/sandbox_gateway.py @@ -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"] diff --git a/apps/api/constraints-x86_64-macos.txt b/apps/api/constraints-x86_64-macos.txt new file mode 100644 index 0000000..cb3cf81 --- /dev/null +++ b/apps/api/constraints-x86_64-macos.txt @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..7435511 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000..5c4ba71 --- /dev/null +++ b/apps/api/pyproject.toml @@ -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 diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..10f4578 --- /dev/null +++ b/apps/api/tests/conftest.py @@ -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 diff --git a/apps/api/tests/test_admin_control_plane.py b/apps/api/tests/test_admin_control_plane.py new file mode 100644 index 0000000..3052204 --- /dev/null +++ b/apps/api/tests/test_admin_control_plane.py @@ -0,0 +1,227 @@ +"""AI Control Plane: access control and the no-leak guarantee. + +Two properties matter more than any feature here: + +1. A normal customer can never reach these endpoints. +2. A provider API key can never come back out of the API. +""" + +from __future__ import annotations + +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + +SECRET_KEY = "sk-super-secret-value-do-not-leak-1234567890" + + +async def test_normal_user_cannot_list_providers(client: AsyncClient): + token = (await signup(client, "peon@example.com"))["access_token"] + response = await client.get("/api/admin/providers", headers=auth_header(token)) + assert response.status_code == 403 + assert response.json()["error"]["code"] == "permission_denied" + + +async def test_normal_user_cannot_create_provider(client: AsyncClient): + token = (await signup(client, "peon2@example.com"))["access_token"] + response = await client.post( + "/api/admin/providers", + json={"name": "evil", "type": "mock", "base_url": ""}, + headers=auth_header(token), + ) + assert response.status_code == 403 + + +async def test_admin_endpoints_require_authentication(client: AsyncClient): + response = await client.get("/api/admin/providers") + assert response.status_code == 401 + + +async def test_platform_owner_can_create_and_list_provider( + client: AsyncClient, platform_owner_token: str +): + created = await client.post( + "/api/admin/providers", + json={"name": "openrouter", "type": "openrouter", "base_url": "https://openrouter.ai/api/v1"}, + headers=auth_header(platform_owner_token), + ) + assert created.status_code == 201, created.text + assert created.json()["name"] == "openrouter" + + listing = await client.get("/api/admin/providers", headers=auth_header(platform_owner_token)) + assert listing.status_code == 200 + assert [p["name"] for p in listing.json()] == ["openrouter"] + + +async def test_duplicate_provider_name_is_rejected(client: AsyncClient, platform_owner_token: str): + payload = {"name": "dupe", "type": "mock", "base_url": ""} + first = await client.post( + "/api/admin/providers", json=payload, headers=auth_header(platform_owner_token) + ) + assert first.status_code == 201 + second = await client.post( + "/api/admin/providers", json=payload, headers=auth_header(platform_owner_token) + ) + assert second.status_code == 409 + + +async def test_credential_creation_returns_only_a_masked_key( + client: AsyncClient, platform_owner_token: str +): + provider = ( + await client.post( + "/api/admin/providers", + json={"name": "p1", "type": "openai_compatible", "base_url": "https://x.test/v1"}, + headers=auth_header(platform_owner_token), + ) + ).json() + + response = await client.post( + f"/api/admin/providers/{provider['id']}/credentials", + json={"name": "primary", "api_key": SECRET_KEY}, + headers=auth_header(platform_owner_token), + ) + assert response.status_code == 201, response.text + body = response.json() + assert SECRET_KEY not in str(body) + assert body["masked_key"].startswith("•") + assert body["masked_key"].endswith(SECRET_KEY[-4:]) + + +async def test_listing_credentials_never_exposes_the_key( + client: AsyncClient, platform_owner_token: str +): + provider = ( + await client.post( + "/api/admin/providers", + json={"name": "p2", "type": "openai_compatible", "base_url": "https://y.test/v1"}, + headers=auth_header(platform_owner_token), + ) + ).json() + await client.post( + f"/api/admin/providers/{provider['id']}/credentials", + json={"name": "primary", "api_key": SECRET_KEY}, + headers=auth_header(platform_owner_token), + ) + + response = await client.get( + f"/api/admin/providers/{provider['id']}/credentials", + headers=auth_header(platform_owner_token), + ) + assert response.status_code == 200 + assert SECRET_KEY not in response.text + assert response.json()[0]["masked_key"] == "••••••" + + +async def test_model_creation_and_listing(client: AsyncClient, platform_owner_token: str): + provider = ( + await client.post( + "/api/admin/providers", + json={"name": "p3", "type": "mock", "base_url": ""}, + headers=auth_header(platform_owner_token), + ) + ).json() + + created = await client.post( + "/api/admin/models", + json={ + "provider_id": provider["id"], + "internal_name": "internal-coder-v1", + "display_name_for_admin": "Coder V1", + "capabilities": ["text", "code"], + "input_price_per_1m": 0.3, + "output_price_per_1m": 1.2, + }, + headers=auth_header(platform_owner_token), + ) + assert created.status_code == 201, created.text + + listing = await client.get("/api/admin/models", headers=auth_header(platform_owner_token)) + assert listing.status_code == 200 + assert listing.json()[0]["internal_name"] == "internal-coder-v1" + + +async def test_model_requires_existing_provider(client: AsyncClient, platform_owner_token: str): + response = await client.post( + "/api/admin/models", + json={ + "provider_id": "does-not-exist", + "internal_name": "x", + "display_name_for_admin": "X", + }, + headers=auth_header(platform_owner_token), + ) + assert response.status_code == 404 + + +async def test_routing_policy_creation_and_default_swap( + client: AsyncClient, platform_owner_token: str +): + first = await client.post( + "/api/admin/routing-policies", + json={"name": "cheap", "mode": "cheap", "is_default": True}, + headers=auth_header(platform_owner_token), + ) + assert first.status_code == 201 + + second = await client.post( + "/api/admin/routing-policies", + json={"name": "coding", "mode": "coding", "is_default": True}, + headers=auth_header(platform_owner_token), + ) + assert second.status_code == 201 + + listing = ( + await client.get("/api/admin/routing-policies", headers=auth_header(platform_owner_token)) + ).json() + defaults = [p for p in listing if p["is_default"]] + assert len(defaults) == 1 + assert defaults[0]["name"] == "coding" + + +async def test_rate_card_upsert_updates_in_place(client: AsyncClient, platform_owner_token: str): + payload = { + "unit": "model_input_tokens", + "name": "input", + "customer_price": 0.0000005, + "internal_cost_estimate": 0.0000002, + } + first = await client.put( + "/api/admin/rate-cards", json=payload, headers=auth_header(platform_owner_token) + ) + assert first.status_code == 200, first.text + + payload["customer_price"] = 0.0000009 + second = await client.put( + "/api/admin/rate-cards", json=payload, headers=auth_header(platform_owner_token) + ) + assert second.json()["customer_price"] == 0.0000009 + + listing = ( + await client.get("/api/admin/rate-cards", headers=auth_header(platform_owner_token)) + ).json() + assert len([c for c in listing if c["unit"] == "model_input_tokens"]) == 1 + + +async def test_platform_owner_sees_all_organizations( + client: AsyncClient, platform_owner_token: str +): + customer = (await signup(client, "tenant-x@example.com"))["access_token"] + await client.post( + "/api/organizations", json={"name": "شرکت مشتری"}, headers=auth_header(customer) + ) + + response = await client.get( + "/api/admin/organizations", headers=auth_header(platform_owner_token) + ) + assert response.status_code == 200 + assert [o["name"] for o in response.json()] == ["شرکت مشتری"] + assert response.json()[0]["member_count"] == 1 + + +async def test_customer_cannot_see_other_organizations_through_admin( + client: AsyncClient, +): + customer = (await signup(client, "tenant-y@example.com"))["access_token"] + response = await client.get("/api/admin/organizations", headers=auth_header(customer)) + assert response.status_code == 403 diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py new file mode 100644 index 0000000..20685b1 --- /dev/null +++ b/apps/api/tests/test_auth.py @@ -0,0 +1,98 @@ +"""Authentication flow tests.""" + +from __future__ import annotations + +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + + +async def test_health_is_public(client: AsyncClient): + response = await client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +async def test_signup_returns_token_and_user(client: AsyncClient): + body = await signup(client, "alice@example.com", name="آلیس") + assert body["access_token"] + assert body["user"]["email"] == "alice@example.com" + assert body["user"]["name"] == "آلیس" + assert body["user"]["is_platform_owner"] is False + assert "password" not in str(body).lower() or "password_hash" not in str(body) + + +async def test_signup_never_returns_password_hash(client: AsyncClient): + body = await signup(client, "hashcheck@example.com") + assert "password_hash" not in body["user"] + assert "password" not in body["user"] + + +async def test_signup_rejects_duplicate_email(client: AsyncClient): + await signup(client, "dup@example.com") + response = await client.post( + "/api/auth/signup", + json={"email": "dup@example.com", "password": "password123", "name": ""}, + ) + assert response.status_code == 409 + assert response.json()["error"]["code"] == "conflict" + + +async def test_signup_rejects_short_password(client: AsyncClient): + response = await client.post( + "/api/auth/signup", + json={"email": "short@example.com", "password": "123", "name": ""}, + ) + assert response.status_code == 422 + + +async def test_login_succeeds_with_correct_password(client: AsyncClient): + await signup(client, "bob@example.com", password="password123") + response = await client.post( + "/api/auth/login", json={"email": "bob@example.com", "password": "password123"} + ) + assert response.status_code == 200 + assert response.json()["access_token"] + + +async def test_login_fails_with_wrong_password(client: AsyncClient): + await signup(client, "carol@example.com", password="password123") + response = await client.post( + "/api/auth/login", json={"email": "carol@example.com", "password": "wrong-password"} + ) + assert response.status_code == 401 + + +async def test_login_is_case_insensitive_on_email(client: AsyncClient): + await signup(client, "Dave@Example.com", password="password123") + response = await client.post( + "/api/auth/login", json={"email": "dave@example.com", "password": "password123"} + ) + assert response.status_code == 200 + + +async def test_me_requires_authentication(client: AsyncClient): + response = await client.get("/api/auth/me") + assert response.status_code == 401 + + +async def test_me_rejects_garbage_token(client: AsyncClient): + response = await client.get("/api/auth/me", headers=auth_header("not-a-real-token")) + assert response.status_code == 401 + + +async def test_me_returns_profile_and_organizations(client: AsyncClient): + body = await signup(client, "erin@example.com", name="ارین") + token = body["access_token"] + + response = await client.get("/api/auth/me", headers=auth_header(token)) + assert response.status_code == 200 + payload = response.json() + assert payload["user"]["email"] == "erin@example.com" + assert payload["organizations"] == [] + + +async def test_logout_is_recorded_and_returns_204(client: AsyncClient): + body = await signup(client, "frank@example.com") + response = await client.post("/api/auth/logout", headers=auth_header(body["access_token"])) + assert response.status_code == 204 diff --git a/apps/api/tests/test_config_guard.py b/apps/api/tests/test_config_guard.py new file mode 100644 index 0000000..999d737 --- /dev/null +++ b/apps/api/tests/test_config_guard.py @@ -0,0 +1,77 @@ +"""Configuration safety guards. + +A production API booted with a placeholder ``JWT_SECRET`` lets anyone forge a +token for any tenant. These tests pin the fail-closed behaviour. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.core.config import DEVELOPMENT_CORS_ORIGINS, MIN_SECRET_LENGTH, Settings + +_SAFE = { + "app_env": "production", + "jwt_secret": "s" * MIN_SECRET_LENGTH, + "encryption_key": "k" * 44, + "cors_origins": "https://app.example.com", +} + + +def _build(**overrides) -> Settings: + return Settings(**{**_SAFE, **overrides}) + + +def test_development_tolerates_placeholder_secrets(): + """A fresh clone must run with zero configuration.""" + settings = Settings( + app_env="development", jwt_secret="change-me", encryption_key="" + ) + assert settings.is_production is False + + +def test_development_cors_allows_both_loopback_spellings(): + """``localhost`` and ``127.0.0.1`` are different browser origins. + + Listing only one makes the dev UI fail with an opaque "Disallowed CORS + origin" — which is exactly what happened when the frontend was started with + ``-H 127.0.0.1`` while the API allowed only ``localhost``. + """ + origins = Settings(app_env="development").cors_origin_list + assert "http://localhost:3000" in origins + assert "http://127.0.0.1:3000" in origins + + +def test_production_rejects_the_placeholder_secret(): + with pytest.raises(ValidationError, match="placeholder"): + _build(jwt_secret="change-me") + + +@pytest.mark.parametrize("secret", ["", "short", "x" * (MIN_SECRET_LENGTH - 1)]) +def test_production_rejects_a_short_secret(secret: str): + with pytest.raises(ValidationError, match="JWT_SECRET"): + _build(jwt_secret=secret) + + +def test_production_requires_an_encryption_key(): + """Without it, stored provider credentials cannot be encrypted at rest.""" + with pytest.raises(ValidationError, match="ENCRYPTION_KEY"): + _build(encryption_key="") + + +def test_production_rejects_wildcard_cors(): + with pytest.raises(ValidationError, match="CORS_ORIGINS"): + _build(cors_origins="*") + + +def test_production_rejects_the_development_cors_default(): + """A production deploy left on the dev allowlist has not been configured.""" + with pytest.raises(ValidationError, match="development default"): + _build(cors_origins=DEVELOPMENT_CORS_ORIGINS) + + +def test_production_accepts_a_fully_configured_environment(): + settings = _build() + assert settings.is_production is True + assert settings.cors_origin_list == ["https://app.example.com"] diff --git a/apps/api/tests/test_domain_isolation.py b/apps/api/tests/test_domain_isolation.py new file mode 100644 index 0000000..0ca4e42 --- /dev/null +++ b/apps/api/tests/test_domain_isolation.py @@ -0,0 +1,51 @@ +"""The domain layer must stay framework-free. + +This is enforced by actually importing the domain modules in a clean +interpreter and asserting that neither FastAPI nor SQLAlchemy got pulled in. +A comment in a README would not catch a regression; this does. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +API_ROOT = Path(__file__).resolve().parents[1] + +_PROBE = """ +import sys + +import app.domain.identity.models +import app.domain.workforce.models +import app.domain.billing.models +import app.domain.sandbox.models +import app.domain.knowledge.models +import app.domain.audit.models +import app.domain.ai_control.models +import app.domain.projects.models +import app.domain.platform.rules + +forbidden = [name for name in ("fastapi", "sqlalchemy", "redis", "httpx") if name in sys.modules] +assert not forbidden, f"domain layer leaked: {forbidden}" +print("clean") +""" + + +def test_domain_layer_has_no_framework_dependencies(): + result = subprocess.run( + [sys.executable, "-c", _PROBE], + capture_output=True, + text=True, + cwd=str(API_ROOT), + ) + assert result.returncode == 0, result.stderr + assert "clean" in result.stdout + + +def test_domain_rules_are_usable_without_a_database(): + from app.domain.platform.rules import build_host, is_port_allocatable + + assert build_host("my-app", "msa.artacloud.ir") == "my-app.msa.artacloud.ir" + assert is_port_allocatable(8100) + assert not is_port_allocatable(3000) diff --git a/apps/api/tests/test_e2e_smoke.py b/apps/api/tests/test_e2e_smoke.py new file mode 100644 index 0000000..cea0171 --- /dev/null +++ b/apps/api/tests/test_e2e_smoke.py @@ -0,0 +1,269 @@ +"""§18.3 — the minimal end-to-end smoke path. + +One test walks the whole product in order, through the real HTTP API and the +real application services, on the mock adapters: + + signup -> organization -> project -> repo -> preview domain -> sandbox + -> employee -> manager message -> task -> run -> usage -> audit + +The point is not coverage of edge cases (the focused suites do that) but to +prove the seams line up: that an object created in one step is actually +accepted by the next one. A green unit suite with a broken seam between two +sections is exactly the failure this test exists to catch. + +It also re-asserts the two product invariants that must hold at every step: +a customer never sees a raw provider/model, and another tenant's objects are +invisible (404, never 403). +""" + +from __future__ import annotations + +import pytest_asyncio +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + + +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + """The platform rows a fresh install has: templates, policies, models, rates.""" + return seeded + + +async def test_end_to_end_smoke_path(client: AsyncClient): + # -- 1. signup -------------------------------------------------------- # + signup_body = await signup(client, "smoke@example.com", name="کاربر دود") + token = signup_body["access_token"] + headers = auth_header(token) + + me = await client.get("/api/auth/me", headers=headers) + assert me.status_code == 200, me.text + assert me.json()["user"]["email"] == "smoke@example.com" + + # -- 2. create organization ------------------------------------------- # + org = await client.post( + "/api/organizations", json={"name": "سازمان دود"}, headers=headers + ) + assert org.status_code == 201, org.text + org_id = org.json()["id"] + assert org.json()["role"] == "owner" + + # -- 3. create project ------------------------------------------------ # + project = await client.post( + f"/api/organizations/{org_id}/projects", + json={"name": "پروژهٔ دود", "description": "مسیر دود انتها به انتها"}, + headers=headers, + ) + assert project.status_code == 201, project.text + project_id = project.json()["id"] + assert project.json()["slug"], "a project must get a slug for its preview host" + + # -- 4. ensure repo --------------------------------------------------- # + repo = await client.post( + f"/api/organizations/{org_id}/projects/{project_id}/repo/ensure", + headers=headers, + ) + assert repo.status_code in (200, 201), repo.text + assert repo.json()["private"] is True, "a customer repo must be private" + + status_body = await client.get( + f"/api/organizations/{org_id}/projects/{project_id}/repo/status", + headers=headers, + ) + assert status_body.status_code == 200, status_body.text + assert status_body.json()["exists"] is True + + # -- 5. create preview domain ----------------------------------------- # + domain = await client.post( + f"/api/organizations/{org_id}/projects/{project_id}/preview/domains", + json={"subdomain": "smoke-app"}, + headers=headers, + ) + assert domain.status_code == 201, domain.text + domain_body = domain.json() + assert domain_body["host"] == "smoke-app.msa.artacloud.ir", domain_body + assert 8080 <= domain_body["port"] <= 8999, "port must come from the managed range" + assert domain_body["protected"] is False + + # -- 6. sandbox workspace + one metered command ------------------------ # + templates = await client.get( + f"/api/organizations/{org_id}/sandbox/templates", headers=headers + ) + assert templates.status_code == 200, templates.text + assert templates.json(), "a seeded install must expose sandbox templates" + template_id = templates.json()[0]["id"] + + workspace = await client.post( + f"/api/organizations/{org_id}/sandbox/workspaces", + json={"project_id": project_id, "template_id": template_id}, + headers=headers, + ) + assert workspace.status_code in (200, 201), workspace.text + assert workspace.json()["status"] in {"ready", "starting", "created"} + + session = await client.post( + f"/api/organizations/{org_id}/sandbox/sessions", + json={"workspace_id": workspace.json()["id"]}, + headers=headers, + ) + assert session.status_code in (200, 201), session.text + session_id = session.json()["id"] + + command = await client.post( + f"/api/organizations/{org_id}/sandbox/sessions/{session_id}/commands", + # A caller that tries to raise its own limits must not be able to: the + # request schema accepts only `command`, so the extra keys are ignored + # and the platform's policy values apply. + json={ + "command": "python -m pytest -q", + "timeout_seconds": 999_999, + "max_output_bytes": 10**9, + }, + headers=headers, + ) + assert command.status_code in (200, 201), command.text + command_body = command.json() + assert command_body["command"] == "python -m pytest -q" + for platform_owned in ("timeout_seconds", "max_output_bytes"): + assert platform_owned not in command_body, ( + f"the caller must not be able to set {platform_owned}" + ) + + # -- 7. create employee ------------------------------------------------ # + agent_templates = await client.get( + f"/api/organizations/{org_id}/agent-templates", headers=headers + ) + assert agent_templates.status_code == 200, agent_templates.text + agent_template = agent_templates.json()[0] + + employee = await client.post( + f"/api/organizations/{org_id}/employees", + json={ + "name": "کارمند دود", + "role_key": agent_template["role_key"], + "template_id": agent_template["id"], + }, + headers=headers, + ) + assert employee.status_code == 201, employee.text + employee_id = employee.json()["id"] + + # The employee record is customer-facing: it must not name a model. + assert "model" not in str(employee.json()).lower() + + # -- 8. send a manager message ----------------------------------------- # + thread = await client.post( + f"/api/organizations/{org_id}/threads", + json={"title": "گفت‌وگو با مدیر", "project_id": project_id}, + headers=headers, + ) + assert thread.status_code == 201, thread.text + thread_id = thread.json()["id"] + + message = await client.post( + f"/api/organizations/{org_id}/threads/{thread_id}/messages", + json={"content": "برای ماژول پرداخت تست بنویس.", "employee_id": employee_id}, + headers=headers, + ) + assert message.status_code in (200, 201), message.text + message_body = message.json() + assert message_body["user_message"]["role"] == "user" + assistant = message_body["assistant_message"] + assert assistant["content"], "the manager must actually answer" + # The reply is customer-facing text: it must not name a model or provider. + assert "provider" not in assistant["content"].lower() + assert "model" not in assistant["content"].lower() + + # -- 9. create task ---------------------------------------------------- # + task = await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "افزودن تست برای ماژول پرداخت", + "description": "پوشش تست واحد برای محاسبهٔ تخفیف", + "project_id": project_id, + "thread_id": thread_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + assert task.status_code == 201, task.text + task_id = task.json()["id"] + + # -- 10. run task ------------------------------------------------------ # + run = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee_id}, + headers=headers, + ) + assert run.status_code in (200, 201), run.text + run_body = run.json() + assert run_body["status"] == "completed", run_body + assert run_body["steps"], "a run must leave a timeline" + assert run_body["artifacts"], "a completed run must produce an artifact" + assert [s["index"] for s in run_body["steps"]] == sorted( + s["index"] for s in run_body["steps"] + ), "timeline steps must be ordered" + + # The run payload is customer-facing: no raw model identity anywhere. + lowered = str(run_body).lower() + for forbidden in ("provider", "api_key", "internal_name", "model"): + assert forbidden not in lowered, f"{forbidden} leaked into the run payload" + + # -- 11. see usage summary --------------------------------------------- # + summary = await client.get( + f"/api/organizations/{org_id}/billing/summary", headers=headers + ) + assert summary.status_code == 200, summary.text + usage = summary.json() + assert usage["total_spend"] > 0, "a completed run must be metered" + assert usage["by_unit"], "usage must be attributable to a billing unit" + assert usage["balance"] is not None + + events = await client.get( + f"/api/organizations/{org_id}/billing/usage-events", headers=headers + ) + assert events.status_code == 200, events.text + assert any(e["run_id"] == run_body["id"] for e in events.json()), ( + "the run's usage must be traceable back to the run" + ) + + # -- 12. audit trail --------------------------------------------------- # + audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers) + assert audit.status_code == 200, audit.text + actions = {e["action"] for e in audit.json()} + for expected in ("organization.create", "project.create", "domain.create"): + assert expected in actions, f"{expected} was not audited" + + +async def test_smoke_path_is_invisible_to_another_tenant(client: AsyncClient): + """The same path, viewed by an outsider: every object must 404, not 403.""" + owner_token = (await signup(client, "smoke-owner@example.com"))["access_token"] + owner_headers = auth_header(owner_token) + org_id = ( + await client.post( + "/api/organizations", json={"name": "سازمان مالک"}, headers=owner_headers + ) + ).json()["id"] + project_id = ( + await client.post( + f"/api/organizations/{org_id}/projects", + json={"name": "پروژهٔ مالک"}, + headers=owner_headers, + ) + ).json()["id"] + + outsider_token = (await signup(client, "smoke-outsider@example.com"))["access_token"] + outsider_headers = auth_header(outsider_token) + + # The outsider is authenticated and knows both ids. + probes = [ + f"/api/organizations/{org_id}", + f"/api/organizations/{org_id}/projects/{project_id}", + f"/api/organizations/{org_id}/employees", + f"/api/organizations/{org_id}/billing/summary", + f"/api/organizations/{org_id}/audit", + f"/api/organizations/{org_id}/sandbox/templates", + ] + for path in probes: + response = await client.get(path, headers=outsider_headers) + assert response.status_code == 404, f"{path} returned {response.status_code}" diff --git a/apps/api/tests/test_error_console.py b/apps/api/tests/test_error_console.py new file mode 100644 index 0000000..7d2471d --- /dev/null +++ b/apps/api/tests/test_error_console.py @@ -0,0 +1,318 @@ +"""The error console API — platform-owner only, grouped, and audited. + +The console is the reason the error table exists at all: an error nobody can +read is not observability, it is a write-only table. These tests cover both +halves — that a regular customer can never reach it, and that the platform owner +can actually triage with it. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest_asyncio +from httpx import AsyncClient + +from app.domain.audit.models import ErrorSeverity, ErrorSource +from app.infrastructure.error_log import record_error +from tests.conftest import auth_header, signup + +CONSOLE = "/api/admin/errors" + + +@pytest_asyncio.fixture(autouse=True) +async def _isolated_error_log(redirect_error_log) -> AsyncIterator[None]: + yield + + +async def _seed_error( + *, + kind: str = "unhandled_exception", + message: str = "boom", + error_type: str = "RuntimeError", + severity: str = ErrorSeverity.ERROR, + source: str = ErrorSource.HTTP, + path: str = "/api/organizations/{id}/projects", + organization_id: str | None = None, + method: str = "GET", + status_code: int | None = 500, +) -> str | None: + """Write one error row through the real recorder, so fingerprints are real.""" + exc: BaseException | None = None + if error_type: + exc = type(error_type, (Exception,), {})(message) + event_id = await record_error( + kind=kind, + message=message, + exc=exc, + severity=severity, + source=source, + path=path, + method=method, + status_code=status_code, + organization_id=organization_id, + ) + assert event_id is not None, "the recorder failed to write a row" + return event_id + + +# --------------------------------------------------------------------------- +# Access control +# --------------------------------------------------------------------------- + + +async def test_a_regular_customer_cannot_reach_the_console(client: AsyncClient): + """The console exposes every tenant's failures; it is platform-owner only.""" + token = (await signup(client, "nosy@example.com"))["access_token"] + for path in ( + CONSOLE, + f"{CONSOLE}/summary", + f"{CONSOLE}/groups", + f"{CONSOLE}/some-id", + ): + response = await client.get(path, headers=auth_header(token)) + assert response.status_code == 403, f"{path} leaked to a customer" + + +async def test_an_anonymous_caller_cannot_reach_the_console(client: AsyncClient): + response = await client.get(CONSOLE) + assert response.status_code == 401 + + +async def test_resolving_requires_a_platform_owner(client: AsyncClient): + event_id = await _seed_error() + token = (await signup(client, "not-owner@example.com"))["access_token"] + response = await client.post( + f"{CONSOLE}/{event_id}/resolve", json={"note": "nope"}, headers=auth_header(token) + ) + assert response.status_code == 403 + + +# --------------------------------------------------------------------------- +# Listing +# --------------------------------------------------------------------------- + + +async def test_errors_are_listed_newest_first( + client: AsyncClient, platform_owner_token: str +): + await _seed_error(message="first failure") + await _seed_error(message="second failure") + + response = await client.get(CONSOLE, headers=auth_header(platform_owner_token)) + assert response.status_code == 200, response.text + body = response.json() + assert body["total"] == 2 + messages = [item["message"] for item in body["items"]] + assert messages == ["second failure", "first failure"] + + +async def test_the_list_carries_everything_needed_to_diagnose( + client: AsyncClient, platform_owner_token: str +): + await _seed_error( + kind="task_run_failed", + message="provider returned garbage", + error_type="ValueError", + status_code=None, + method="", + path="", + ) + + item = ( + await client.get(CONSOLE, headers=auth_header(platform_owner_token)) + ).json()["items"][0] + for field in ( + "id", "kind", "message", "stack", "severity", "source", "error_type", + "fingerprint", "status_code", "method", "path", "organization_id", + "user_id", "request_id", "ip", "user_agent", "metadata_json", + "resolved_at", "resolved_note", "created_at", + ): + assert field in item, f"the console row is missing {field}" + assert item["kind"] == "task_run_failed" + assert item["error_type"] == "ValueError" + + +async def test_filters_narrow_the_list( + client: AsyncClient, platform_owner_token: str +): + await _seed_error(message="http thing", source=ErrorSource.HTTP) + await _seed_error(message="worker thing", source=ErrorSource.WORKER, method="", path="") + + headers = auth_header(platform_owner_token) + by_source = await client.get(f"{CONSOLE}?source=worker", headers=headers) + assert [i["message"] for i in by_source.json()["items"]] == ["worker thing"] + + by_severity = await client.get(f"{CONSOLE}?severity=error", headers=headers) + assert by_severity.json()["total"] == 2 + + by_search = await client.get(f"{CONSOLE}?search=worker", headers=headers) + assert [i["message"] for i in by_search.json()["items"]] == ["worker thing"] + + +async def test_the_page_size_is_capped(client: AsyncClient, platform_owner_token: str): + """An uncapped limit would let one request pull every stack trace at once.""" + response = await client.get( + f"{CONSOLE}?limit=100000", headers=auth_header(platform_owner_token) + ) + assert response.status_code == 422 + + +async def test_unknown_error_id_is_a_404(client: AsyncClient, platform_owner_token: str): + response = await client.get(f"{CONSOLE}/nope", headers=auth_header(platform_owner_token)) + assert response.status_code == 404 + assert response.json()["error"]["code"] == "not_found" + + +async def test_summary_counts_open_failures_by_severity( + client: AsyncClient, platform_owner_token: str +): + await _seed_error(severity=ErrorSeverity.CRITICAL) + await _seed_error(severity=ErrorSeverity.ERROR) + await _seed_error(severity=ErrorSeverity.WARNING) + + body = ( + await client.get(f"{CONSOLE}/summary", headers=auth_header(platform_owner_token)) + ).json() + assert body["total_open"] == 3 + assert body["open_by_severity"] == {"critical": 1, "error": 1, "warning": 1} + + +# --------------------------------------------------------------------------- +# Grouping — the view that makes the console usable +# --------------------------------------------------------------------------- + + +async def test_identical_failures_collapse_into_one_group( + client: AsyncClient, platform_owner_token: str +): + """A bug that fired 4 000 times must be one line with a count, not 4 000.""" + for entity in ("aaaa1111bbbb2222cccc3333dddd4444", "9999eeee8888ffff7777aaaa6666bbbb"): + await _seed_error( + message="task not found while starting a run", + path=f"/api/organizations/{entity}/projects", + ) + await _seed_error(message="a completely different failure", path="/api/other") + + groups = ( + await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token)) + ).json() + assert len(groups) == 2 + biggest = max(groups, key=lambda g: g["occurrences"]) + assert biggest["occurrences"] == 2 + assert biggest["open_count"] == 2 + assert biggest["sample_message"] == "task not found while starting a run" + assert biggest["first_seen"] and biggest["last_seen"] + + +async def test_group_listing_is_reachable_before_the_id_route( + client: AsyncClient, platform_owner_token: str +): + """"groups" must not be swallowed by ``/errors/{error_id}``.""" + response = await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token)) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +# --------------------------------------------------------------------------- +# Triage +# --------------------------------------------------------------------------- + + +async def test_resolving_a_group_closes_every_open_occurrence( + client: AsyncClient, platform_owner_token: str +): + await _seed_error(message="the same bug", path="/api/x") + await _seed_error(message="the same bug", path="/api/x") + + headers = auth_header(platform_owner_token) + fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] + + resolved = await client.post( + f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "fixed in v2"}, headers=headers + ) + assert resolved.status_code == 200, resolved.text + assert resolved.json()["resolved"] == 2 + + # Nothing is left open… + assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 0 + # …and the note is on the records. + closed = (await client.get(f"{CONSOLE}?status=resolved", headers=headers)).json()["items"] + assert all(item["resolved_note"] == "fixed in v2" for item in closed) + assert all(item["resolved_at"] for item in closed) + + +async def test_resolving_a_group_is_audited( + client: AsyncClient, platform_owner_token: str, session_factory +): + """"Who decided this bug was fixed?" must have an answer.""" + from sqlalchemy import select + + from app.infrastructure.db.models import AuditEventRow + + await _seed_error(message="auditable bug", path="/api/y") + headers = auth_header(platform_owner_token) + fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] + + await client.post( + f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "done"}, headers=headers + ) + + async with session_factory() as session: + entries = list( + ( + await session.execute( + select(AuditEventRow).where(AuditEventRow.action == "error.resolve") + ) + ).scalars() + ) + assert len(entries) == 1, "triaging an error must leave a trail" + assert entries[0].target_id == fingerprint + assert entries[0].actor_user_id + assert entries[0].metadata_json["resolved"] == 1 + assert entries[0].metadata_json["note"] == "done" + + +async def test_reopening_a_group_brings_the_bug_back( + client: AsyncClient, platform_owner_token: str +): + await _seed_error(message="flaky bug", path="/api/z") + headers = auth_header(platform_owner_token) + fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] + + await client.post( + f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "ok"}, headers=headers + ) + reopened = await client.post(f"{CONSOLE}/groups/{fingerprint}/reopen", headers=headers) + assert reopened.json()["resolved"] == 1 + + assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 1 + + +async def test_resolving_an_already_closed_group_is_a_404( + client: AsyncClient, platform_owner_token: str +): + """Rather than silently reporting success on a no-op.""" + await _seed_error(message="close me twice", path="/api/w") + headers = auth_header(platform_owner_token) + fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] + + await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers) + second = await client.post( + f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers + ) + assert second.status_code == 404 + + +async def test_the_open_filter_hides_resolved_groups( + client: AsyncClient, platform_owner_token: str +): + await _seed_error(message="will be closed", path="/api/closed") + headers = auth_header(platform_owner_token) + fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] + await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers) + + assert (await client.get(f"{CONSOLE}/groups?status=open", headers=headers)).json() == [] + resolved = (await client.get(f"{CONSOLE}/groups?status=resolved", headers=headers)).json() + assert len(resolved) == 1 + assert resolved[0]["open_count"] == 0 diff --git a/apps/api/tests/test_error_log.py b/apps/api/tests/test_error_log.py new file mode 100644 index 0000000..6fab4a1 --- /dev/null +++ b/apps/api/tests/test_error_log.py @@ -0,0 +1,474 @@ +"""The error pipeline: from a raised exception to a row in the database. + +These tests drive the real FastAPI app with the real middleware and the real +exception handlers, against the real recorder — only the database connection is +redirected. A unit test of the handlers would have missed the middleware, and a +unit test of the recorder would have missed the handlers; the value here is that +the whole path is exercised end to end. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest_asyncio +from fastapi import APIRouter, Depends +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.core.config import settings +from app.core.database import get_session +from app.core.errors import ProviderUnavailableError +from app.infrastructure.db.models import ErrorEventRow +from app.interfaces.http.deps import OrgContext, get_org_context +from app.interfaces.http.request_context import REQUEST_ID_HEADER +from app.main import app +from tests.conftest import auth_header, signup + +#: The password in this DSN must never reach the database. +LEAKY_DSN = "postgresql://agy:sup3rs3cret@db.internal:5432/agy" + +TEST_PREFIX = "/api/__errortest__" + + +@pytest_asyncio.fixture +async def client(engine, session_factory): + """The shared test client, except that app exceptions are not re-raised. + + Starlette's ``ServerErrorMiddleware`` deliberately sends the 500 response + and *then* re-raises, so the ASGI server can log it. httpx mirrors that + re-raise by default, which would surface every intentional 500 as a test + error instead of a response. These tests care about what the caller + receives, so the re-raise is switched off. + """ + + 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, raise_app_exceptions=False), + base_url="http://test", + ) as test_client: + yield test_client + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture(autouse=True) +async def _recorder_uses_the_test_database(redirect_error_log) -> AsyncIterator[None]: + """Redirect the recorder and the workers to the in-memory database. + + See ``conftest.redirect_error_log``. Autouse here because every test in this + module deliberately triggers a failure path. + """ + yield + + +@pytest_asyncio.fixture +async def failing_routes() -> AsyncIterator[None]: + """Mount throwaway routes that fail on purpose. + + Registered on the real app so the real middleware, handlers and dependencies + run. Torn down afterwards so no other test can see them. + """ + router = APIRouter() + + @router.get(f"{TEST_PREFIX}/unhandled") + async def _unhandled() -> None: + raise RuntimeError(f"kaboom while dialling {LEAKY_DSN}") + + @router.get(f"{TEST_PREFIX}/provider-down") + async def _provider_down() -> None: + raise ProviderUnavailableError("سرویس‌دهنده در دسترس نیست.") + + @router.get(f"{TEST_PREFIX}/value-error") + async def _value_error() -> None: + raise ValueError("chunk size must be positive") + + @router.get(f"{TEST_PREFIX}/org/{{org_id}}") + async def _org_scoped(context: OrgContext = Depends(get_org_context)) -> None: + raise RuntimeError(f"failed inside org {context.org_id}") + + app.include_router(router) + yield + app.router.routes = [ + route + for route in app.router.routes + if not getattr(route, "path", "").startswith(TEST_PREFIX) + ] + + +async def _rows(session_factory) -> list[ErrorEventRow]: + async with session_factory() as session: + result = await session.execute(select(ErrorEventRow).order_by(ErrorEventRow.created_at)) + return list(result.scalars()) + + +async def _only_row(session_factory) -> ErrorEventRow: + rows = await _rows(session_factory) + assert len(rows) == 1, f"expected exactly one error row, found {len(rows)}" + return rows[0] + + +# --------------------------------------------------------------------------- +# Request ids +# --------------------------------------------------------------------------- + + +async def test_every_response_carries_a_request_id(client: AsyncClient): + response = await client.get("/health") + assert response.status_code == 200 + assert response.headers.get(REQUEST_ID_HEADER) + + +async def test_caller_supplied_request_id_is_honoured(client: AsyncClient): + """Lets an upstream proxy or BFF correlate its logs with ours.""" + response = await client.get("/health", headers={REQUEST_ID_HEADER: "trace-from-upstream"}) + assert response.headers[REQUEST_ID_HEADER] == "trace-from-upstream" + + +async def test_an_absurdly_long_caller_request_id_is_capped(client: AsyncClient): + response = await client.get("/health", headers={REQUEST_ID_HEADER: "x" * 500}) + assert len(response.headers[REQUEST_ID_HEADER]) <= 64 + + +# --------------------------------------------------------------------------- +# Unhandled exceptions +# --------------------------------------------------------------------------- + + +async def test_unhandled_exception_is_persisted_with_full_context( + client: AsyncClient, failing_routes: None, session_factory +): + response = await client.get(f"{TEST_PREFIX}/unhandled", headers={REQUEST_ID_HEADER: "req-1"}) + + assert response.status_code == 500 + # The caller is told the request id so a bug report is actionable — in the + # body *and* in the header. Starlette's ServerErrorMiddleware turns an + # unhandled exception into a response outside every user middleware, so the + # header has to be set by the handler; a middleware-only approach silently + # loses it on exactly the responses that matter most. + assert response.json()["error"]["request_id"] == "req-1" + assert response.headers.get(REQUEST_ID_HEADER) == "req-1" + + row = await _only_row(session_factory) + assert row.kind == "unhandled_exception" + assert row.severity == "critical" + assert row.source == "http" + assert row.error_type == "RuntimeError" + assert row.status_code == 500 + assert row.method == "GET" + assert row.path == f"{TEST_PREFIX}/unhandled" + assert row.request_id == "req-1" + assert row.fingerprint + assert row.stack, "a traceback is the single most useful field for fixing a bug" + assert row.resolved_at is None + + +async def test_error_responses_carry_the_header_for_every_handler( + client: AsyncClient, failing_routes: None +): + """Each handler path must set the header, not just the body.""" + checks = [ + (f"{TEST_PREFIX}/unhandled", 500), + (f"{TEST_PREFIX}/provider-down", 503), + (f"{TEST_PREFIX}/value-error", 422), + ] + for path, expected in checks: + response = await client.get(path, headers={REQUEST_ID_HEADER: "hdr-check"}) + assert response.status_code == expected, path + assert response.headers.get(REQUEST_ID_HEADER) == "hdr-check", path + assert response.json()["error"]["request_id"] == "hdr-check", path + + # A Pydantic validation failure, which uses FastAPI's own exception type. + response = await client.post( + "/api/auth/login", json={"email": "nope"}, headers={REQUEST_ID_HEADER: "hdr-check"} + ) + assert response.status_code == 422 + assert response.headers.get(REQUEST_ID_HEADER) == "hdr-check" + + +async def test_a_routing_404_still_carries_the_header(client: AsyncClient): + """Handled by Starlette's own middleware, so it exercises a different path.""" + response = await client.get("/api/definitely-not-a-route") + assert response.status_code == 404 + assert response.headers.get(REQUEST_ID_HEADER) + + +async def test_a_secret_in_the_exception_message_never_reaches_the_database( + client: AsyncClient, failing_routes: None, session_factory +): + """The whole reason the redactor exists. + + The exception interpolates a DSN containing a password; the stored message + must not contain it, and neither must the stored stack. + """ + await client.get(f"{TEST_PREFIX}/unhandled") + + row = await _only_row(session_factory) + assert "sup3rs3cret" not in row.message + assert "sup3rs3cret" not in row.stack + assert "db.internal" in row.message # the useful part survives + + +async def test_org_and_user_are_attributed_through_the_real_dependency( + client: AsyncClient, failing_routes: None, session_factory +): + token = (await signup(client, "errlog-org@example.com"))["access_token"] + org = ( + await client.post( + "/api/organizations", json={"name": "سازمان آزمایشی"}, headers=auth_header(token) + ) + ).json() + + response = await client.get( + f"{TEST_PREFIX}/org/{org['id']}", headers=auth_header(token) + ) + assert response.status_code == 500 + + row = await _only_row(session_factory) + assert row.organization_id == org["id"] + assert row.user_id == org["owner_user_id"] + + +async def test_a_failure_outside_any_tenant_still_records( + client: AsyncClient, failing_routes: None, session_factory +): + await client.get(f"{TEST_PREFIX}/unhandled") + row = await _only_row(session_factory) + assert row.organization_id is None + assert row.user_id is None + + +# --------------------------------------------------------------------------- +# Which failures get recorded +# --------------------------------------------------------------------------- + + +async def test_server_side_app_error_is_always_recorded( + client: AsyncClient, failing_routes: None, session_factory +): + """A 503 is an operational fault, not caller error — the switch must not + suppress it.""" + response = await client.get(f"{TEST_PREFIX}/provider-down") + assert response.status_code == 503 + + row = await _only_row(session_factory) + assert row.kind == "provider_unavailable" + assert row.severity == "error" + assert row.status_code == 503 + + +async def test_client_errors_are_not_recorded_by_default( + client: AsyncClient, session_factory +): + """A public API takes constant 404s from scanners; recording them would + bury real bugs.""" + assert settings.error_log_client_errors is False + response = await client.get("/api/organizations/does-not-exist") + assert response.status_code in (401, 404) + assert await _rows(session_factory) == [] + + +async def test_client_errors_are_recorded_when_enabled( + client: AsyncClient, session_factory, monkeypatch +): + monkeypatch.setattr(settings, "error_log_client_errors", True) + token = (await signup(client, "errlog-404@example.com"))["access_token"] + response = await client.get("/api/organizations/nope", headers=auth_header(token)) + assert response.status_code == 404 + + row = await _only_row(session_factory) + assert row.kind == "not_found" + assert row.severity == "warning", "a 404 must never look like a server fault" + assert row.status_code == 404 + + +async def test_a_bare_value_error_is_recorded_even_with_client_errors_off( + client: AsyncClient, failing_routes: None, session_factory +): + """``ValueError`` escapes are a typing gap, not caller input: the codebase + has a typed ``ValidationError`` for that. They are always worth seeing.""" + assert settings.error_log_client_errors is False + response = await client.get(f"{TEST_PREFIX}/value-error") + assert response.status_code == 422 + + row = await _only_row(session_factory) + assert row.kind == "value_error" + assert row.severity == "warning" + assert row.error_type == "ValueError" + + +async def test_validation_failures_use_the_standard_envelope( + client: AsyncClient, session_factory +): + """The web client reads ``error.code``/``error.message``; FastAPI's default + ``{"detail": [...]}`` is unreadable to it.""" + response = await client.post("/api/auth/login", json={"email": "not-an-email"}) + assert response.status_code == 422 + body = response.json() + assert body["error"]["code"] == "validation_error" + assert body["error"]["message"] + assert body["error"]["request_id"] + assert isinstance(body["error"]["details"], list) + + +async def test_the_switch_can_turn_recording_off_entirely( + client: AsyncClient, failing_routes: None, session_factory, monkeypatch +): + monkeypatch.setattr(settings, "error_log_enabled", False) + response = await client.get(f"{TEST_PREFIX}/unhandled") + + # The response is unchanged — only the persistence is skipped. + assert response.status_code == 500 + assert response.json()["error"]["request_id"] + assert await _rows(session_factory) == [] + + +# --------------------------------------------------------------------------- +# Resilience — recording must never make things worse +# --------------------------------------------------------------------------- + + +async def test_a_broken_recorder_does_not_break_the_response( + client: AsyncClient, failing_routes: None, monkeypatch +): + """If the database is unreachable, the caller must still get a clean 500. + + This is the failure mode that matters most: the errors you cannot record are + exactly the database errors, and they must not cascade into a hang or an + unhandled exception inside the exception handler. + """ + import app.infrastructure.error_log as error_log + + class _Exploding: + def __call__(self, *args, **kwargs): + raise RuntimeError("database is unreachable") + + monkeypatch.setattr(error_log, "SessionFactory", _Exploding()) + + response = await client.get(f"{TEST_PREFIX}/unhandled", headers={REQUEST_ID_HEADER: "req-x"}) + assert response.status_code == 500 + assert response.json()["error"]["code"] == "internal_error" + assert response.json()["error"]["request_id"] == "req-x" + + +async def test_metadata_that_is_not_serialisable_does_not_raise( + client: AsyncClient, failing_routes: None, session_factory +): + """``_safe_metadata`` must degrade, not explode, on odd payloads.""" + from app.infrastructure.error_log import _safe_metadata + + class _Weird: + def __repr__(self) -> str: + return "" + + result = _safe_metadata({"obj": _Weird(), "password": "hunter2"}) + assert result["password"] == "[REDACTED]" + assert "weird" in str(result["obj"]) + + +# --------------------------------------------------------------------------- +# Worker and startup failures +# --------------------------------------------------------------------------- + + +async def test_a_failed_worker_pass_is_persisted(seeded, session_factory): + """A worker that only logged to stderr would lose the failure the moment the + container was replaced.""" + import asyncio + + from app.workers.base import run_periodically + + async def exploding_pass(): + raise RuntimeError("billing rollup blew up") + + outcomes = await run_periodically( + exploding_pass, + interval_seconds=1, + stop=asyncio.Event(), + run_once=True, + worker_name="billing", + ) + + # The pass is reported as failed, so a cron entry exits non-zero instead of + # silently succeeding. + assert len(outcomes) == 1 + assert outcomes[0].failed == 1 + assert outcomes[0].name == "billing" + + row = await _only_row(session_factory) + assert row.kind == "worker_pass_failed" + assert row.source == "worker" + assert row.severity == "error" + assert row.error_type == "RuntimeError" + assert row.metadata_json["worker"] == "billing" + assert row.stack + # A worker pass has no HTTP request behind it. + assert row.path == "" + assert row.status_code is None + + +async def test_a_failed_task_run_is_persisted_with_its_tenant( + seeded, client: AsyncClient, session_factory, monkeypatch +): + """A task that cannot run is a tenant-visible problem, so the row must name + the organization — otherwise nobody can find it in the console.""" + from app.application.services.workforce_service import WorkforceService + from app.workers.ai_runtime import AiRuntimeWorker + from tests.test_workers import _make_org_with_employee + + org_id, headers, project_id, employee_id = await _make_org_with_employee( + client, "errlog-worker@example.com" + ) + task_id = ( + await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "کاری که شکست می‌خورد", + "project_id": project_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + ).json()["id"] + + async def _boom(*args, **kwargs): + raise RuntimeError("provider returned a malformed response") + + monkeypatch.setattr(WorkforceService, "start_run", _boom) + + outcome = await AiRuntimeWorker().drain() + + assert outcome.failed == 1, outcome.notes + row = await _only_row(session_factory) + assert row.kind == "task_run_failed" + assert row.source == "worker" + assert row.organization_id == org_id + assert row.metadata_json["task_id"] == task_id + assert row.metadata_json["employee_id"] == employee_id + + +async def test_a_startup_failure_is_persisted_as_critical( + seeded, session_factory +): + """Boot failures are the hardest to diagnose after the fact: the process is + gone and stderr is usually discarded.""" + from app.main import _record_startup_failure + + try: + raise RuntimeError("could not create the schema") + except RuntimeError as exc: + await _record_startup_failure(exc) + + row = await _only_row(session_factory) + assert row.kind == "startup_failed" + assert row.source == "startup" + assert row.severity == "critical" + assert row.metadata_json["app_env"] == settings.app_env diff --git a/apps/api/tests/test_pagination.py b/apps/api/tests/test_pagination.py new file mode 100644 index 0000000..8e896cb --- /dev/null +++ b/apps/api/tests/test_pagination.py @@ -0,0 +1,96 @@ +"""§20 — list endpoints are paginated. + +Every tenant-scoped list is unbounded in principle: an organization can create +thousands of tasks. Without a page size the API would happily serialize all of +them, so these tests pin the contract that a list is capped and that ``offset`` +walks the result set. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + + +# Fixtures resolve from conftest by name, so ``seeded`` needs no import here; +# importing it would shadow this parameter (ruff F811) and read as unused (F401). +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + return seeded + + +async def test_tasks_list_is_paginated(client: AsyncClient): + token = (await signup(client, "page-tasks@example.com"))["access_token"] + headers = auth_header(token) + org_id = ( + await client.post("/api/organizations", json={"name": "سازمان صفحه"}, headers=headers) + ).json()["id"] + + for index in range(5): + created = await client.post( + f"/api/organizations/{org_id}/tasks", + json={"title": f"کار {index}"}, + headers=headers, + ) + assert created.status_code == 201, created.text + + page_one = await client.get( + f"/api/organizations/{org_id}/tasks", params={"limit": 2}, headers=headers + ) + assert page_one.status_code == 200, page_one.text + assert len(page_one.json()) == 2, "limit must cap the page size" + + page_two = await client.get( + f"/api/organizations/{org_id}/tasks", + params={"limit": 2, "offset": 2}, + headers=headers, + ) + assert len(page_two.json()) == 2 + + # Pages must not overlap: offset walks the ordering rather than repeating it. + ids_one = {t["id"] for t in page_one.json()} + ids_two = {t["id"] for t in page_two.json()} + assert not (ids_one & ids_two), "consecutive pages returned the same row" + + everything = await client.get( + f"/api/organizations/{org_id}/tasks", params={"limit": 500}, headers=headers + ) + assert len(everything.json()) == 5 + + +@pytest.mark.parametrize("bad", [{"limit": 0}, {"limit": 501}, {"offset": -1}]) +async def test_out_of_range_pagination_is_rejected(client: AsyncClient, bad: dict): + token = (await signup(client, f"page-bad-{abs(hash(str(bad))) % 10**6}@example.com"))[ + "access_token" + ] + headers = auth_header(token) + org_id = ( + await client.post( + "/api/organizations", json={"name": "سازمان اعتبار"}, headers=headers + ) + ).json()["id"] + + response = await client.get( + f"/api/organizations/{org_id}/tasks", params=bad, headers=headers + ) + assert response.status_code == 422, response.text + + +async def test_projects_and_employees_accept_pagination(client: AsyncClient): + """The parameter is accepted consistently across the tenant-scoped lists.""" + token = (await signup(client, "page-multi@example.com"))["access_token"] + headers = auth_header(token) + org_id = ( + await client.post("/api/organizations", json={"name": "سازمان چندگانه"}, headers=headers) + ).json()["id"] + + for path in ("projects", "employees", "threads", "tasks"): + response = await client.get( + f"/api/organizations/{org_id}/{path}", + params={"limit": 10, "offset": 0}, + headers=headers, + ) + assert response.status_code == 200, f"{path}: {response.text}" diff --git a/apps/api/tests/test_platform_rules.py b/apps/api/tests/test_platform_rules.py new file mode 100644 index 0000000..0e2146b --- /dev/null +++ b/apps/api/tests/test_platform_rules.py @@ -0,0 +1,119 @@ +"""Platform safety rules: subdomain validation, proxy targets, port ranges.""" + +from __future__ import annotations + +import pytest + +from app.domain.platform.rules import ( + RuleViolation, + assert_not_protected_port, + assert_port_allocatable, + build_host, + is_port_allocatable, + validate_managed_host, + validate_proxy_target, + validate_subdomain_label, +) + + +@pytest.mark.parametrize( + "label", ["my-app", "app1", "a" * 40, "abc"] +) +def test_valid_labels(label: str): + assert validate_subdomain_label(label) == label + + +@pytest.mark.parametrize( + "label", + [ + "ab", # too short + "a" * 41, # too long + "-abc", # leading hyphen + "abc-", # trailing hyphen + "My-App", # uppercase is normalised, but this one has no other issue + "app_name", # underscore not allowed + "app.name", # dot not allowed + "app name", # whitespace + "app{}", # brace injection attempt + "app\nother", # newline injection attempt + "", # empty + ], +) +def test_invalid_labels(label: str): + if label == "My-App": + # Documented behaviour: input is lower-cased, so this is accepted as "my-app". + assert validate_subdomain_label(label) == "my-app" + return + with pytest.raises(RuleViolation): + validate_subdomain_label(label) + + +def test_build_host_appends_root_domain(): + assert build_host("shop", "msa.artacloud.ir") == "shop.msa.artacloud.ir" + + +def test_managed_host_rejects_foreign_domain(): + with pytest.raises(RuleViolation): + validate_managed_host("evil.example.com", "msa.artacloud.ir") + + +def test_managed_host_rejects_nested_label(): + with pytest.raises(RuleViolation): + validate_managed_host("a.b.msa.artacloud.ir", "msa.artacloud.ir") + + +def test_managed_host_rejects_injection_attempt(): + with pytest.raises(RuleViolation): + validate_managed_host("app { reverse_proxy evil:80 }.msa.artacloud.ir", "msa.artacloud.ir") + + +def test_managed_host_accepts_valid_host(): + assert ( + validate_managed_host("my-app.msa.artacloud.ir", "msa.artacloud.ir") + == "my-app.msa.artacloud.ir" + ) + + +@pytest.mark.parametrize( + "target", + ["localhost:8080", "127.0.0.1:8080", "http://localhost:8080", "http://127.0.0.1:9000"], +) +def test_valid_proxy_targets(target: str): + host, port = validate_proxy_target(target) + assert host in {"localhost", "127.0.0.1"} + assert 1 <= port <= 65535 + + +@pytest.mark.parametrize( + "target", + [ + "example.com:80", + "169.254.169.254:80", # cloud metadata endpoint + "10.0.0.5:8080", # internal network + "localhost", # no port + "localhost:notaport", + "0.0.0.0:8080", + ], +) +def test_ssrf_proxy_targets_rejected(target: str): + with pytest.raises(RuleViolation): + validate_proxy_target(target) + + +@pytest.mark.parametrize("port", [22, 53, 80, 443, 3000, 5432, 6379, 8008, 2019, 7999, 9000]) +def test_reserved_and_out_of_range_ports_rejected(port: int): + assert not is_port_allocatable(port) + with pytest.raises(RuleViolation): + assert_port_allocatable(port) + + +@pytest.mark.parametrize("port", [8080, 8500, 8999]) +def test_allocatable_ports(port: int): + assert is_port_allocatable(port) + assert assert_port_allocatable(port) == port + + +@pytest.mark.parametrize("port", [3000, 35555, 35556, 35557, 5432]) +def test_protected_ports_cannot_be_proxied(port: int): + with pytest.raises(RuleViolation): + assert_not_protected_port(port) diff --git a/apps/api/tests/test_project_repo.py b/apps/api/tests/test_project_repo.py new file mode 100644 index 0000000..3828cb8 --- /dev/null +++ b/apps/api/tests/test_project_repo.py @@ -0,0 +1,117 @@ +"""Project Git repository endpoints. + +These endpoints had **no test coverage** and were broken at runtime: the domain +entity and the infrastructure repository that manages ``Project`` rows were both +named ``ProjectRepository``, so the infrastructure class shadowed the entity and +``ensure_repository`` constructed it with ``project_id=`` — a ``TypeError`` that +turned every call into a 500. + +The tests below exist to keep that from regressing, and to pin the security +properties the module promises: repositories are always private, and the Git +password never reaches a response body. +""" + +from __future__ import annotations + +import json + +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + + +async def _org_and_project(client: AsyncClient, token: str) -> tuple[str, str]: + org = await client.post( + "/api/organizations", json={"name": "شرکت مخزن"}, headers=auth_header(token) + ) + assert org.status_code == 201, org.text + org_id = org.json()["id"] + + project = await client.post( + f"/api/organizations/{org_id}/projects", + json={"name": "پروژهٔ آزمایشی", "description": "برای تست مخزن"}, + headers=auth_header(token), + ) + assert project.status_code == 201, project.text + return org_id, project.json()["id"] + + +async def test_ensure_repository_creates_a_private_repo(client: AsyncClient): + """The exact path that used to raise TypeError -> 500.""" + token = (await signup(client, "repo-owner@example.com"))["access_token"] + org_id, project_id = await _org_and_project(client, token) + base = f"/api/organizations/{org_id}/projects/{project_id}" + + response = await client.post(f"{base}/repo/ensure", headers=auth_header(token)) + assert response.status_code in (200, 201), response.text + + body = response.json() + assert body["owner"] + assert body["repo_name"] + assert body["clone_url"] + # A tenant repository must never be created public. + assert body.get("private", True) is True + + +async def test_repo_status_is_reported_after_ensure(client: AsyncClient): + token = (await signup(client, "repo-status@example.com"))["access_token"] + org_id, project_id = await _org_and_project(client, token) + base = f"/api/organizations/{org_id}/projects/{project_id}" + + # Before ensure, the platform has no record of a repository. + before = await client.get(f"{base}/repo/status", headers=auth_header(token)) + assert before.status_code == 200, before.text + assert before.json()["exists"] is False + + await client.post(f"{base}/repo/ensure", headers=auth_header(token)) + + after = await client.get(f"{base}/repo/status", headers=auth_header(token)) + assert after.status_code == 200, after.text + assert after.json()["exists"] is True + + +async def test_git_password_never_leaves_the_api(client: AsyncClient): + """`ensure_credentials` returns plaintext to the service layer only. + + The regression this guards against is a future refactor adding the + credential object straight to a response model. + """ + token = (await signup(client, "repo-secret@example.com"))["access_token"] + org_id, project_id = await _org_and_project(client, token) + base = f"/api/organizations/{org_id}/projects/{project_id}" + + await client.post(f"{base}/repo/ensure", headers=auth_header(token)) + + for path in (f"{base}/repo", f"{base}/repo/status"): + response = await client.get(path, headers=auth_header(token)) + assert response.status_code == 200, response.text + payload = json.dumps(response.json()) + for forbidden in ("password", "encrypted_password", "token"): + assert forbidden not in payload.lower(), f"{forbidden} leaked via {path}" + + +async def test_repository_of_another_tenant_is_not_reachable(client: AsyncClient): + """Tenant isolation must hold on the repo sub-resources too.""" + token_a = (await signup(client, "repo-a@example.com"))["access_token"] + token_b = (await signup(client, "repo-b@example.com"))["access_token"] + org_a, project_a = await _org_and_project(client, token_a) + + # B is authenticated but not a member of A's organization. + response = await client.post( + f"/api/organizations/{org_a}/projects/{project_a}/repo/ensure", + headers=auth_header(token_b), + ) + # 404 rather than 403, so the API does not confirm the project exists. + assert response.status_code == 404, response.text + + +async def test_ensure_repository_is_idempotent(client: AsyncClient): + token = (await signup(client, "repo-idem@example.com"))["access_token"] + org_id, project_id = await _org_and_project(client, token) + base = f"/api/organizations/{org_id}/projects/{project_id}" + + first = await client.post(f"{base}/repo/ensure", headers=auth_header(token)) + second = await client.post(f"{base}/repo/ensure", headers=auth_header(token)) + assert first.status_code in (200, 201), first.text + assert second.status_code in (200, 201), second.text + assert first.json()["repo_name"] == second.json()["repo_name"] diff --git a/apps/api/tests/test_redact.py b/apps/api/tests/test_redact.py new file mode 100644 index 0000000..0669ec9 --- /dev/null +++ b/apps/api/tests/test_redact.py @@ -0,0 +1,221 @@ +"""The redactor is the only thing standing between an exception payload and a +password in the database. These tests try to defeat it the way real code does: +secrets nested in dicts, buried in DSNs, wearing an unexpected key name. +""" + +from __future__ import annotations + +import pytest + +from app.core.redact import ( + REDACTED, + is_sensitive_key, + redact, + redact_headers, + redact_mapping, + redact_text, +) + +# --------------------------------------------------------------------------- +# Structural layer — sensitive keys +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "key", + [ + "password", + "PASSWORD", + "Passwd", + "api_key", + "apiKey", + "X-API-Key", + "authorization", + "Authorization", + "access_token", + "refreshToken", + "client_secret", + "private_key", + "gitea_admin_token", + "stripe_secret_key", + "db_password", + "encryption_key", + ], +) +def test_sensitive_keys_are_recognised(key: str) -> None: + assert is_sensitive_key(key) is True + + +@pytest.mark.parametrize( + "key", + [ + "token_count", + "input_tokens", + "output_tokens", + "max_tokens", + "total_tokens", + "name", + "email", + "organization_id", + "command", + "status_code", + ], +) +def test_usage_counters_and_ordinary_keys_are_not_sensitive(key: str) -> None: + """``input_tokens`` is billing data, not a credential. + + Over-eager matching here would quietly destroy the usage numbers the error + log exists to help debug. + """ + assert is_sensitive_key(key) is False + + +def test_sensitive_key_value_is_replaced_whatever_it_looks_like() -> None: + # The value has no recognisable shape at all — only the key gives it away. + payload = {"password": "correct horse battery staple", "username": "demo"} + result = redact_mapping(payload) + assert result["password"] == REDACTED + assert result["username"] == "demo" + + +def test_redaction_reaches_into_nested_structures() -> None: + payload = { + "request": { + "headers": {"Authorization": "Bearer abcdefghijklmnop"}, + "body": {"user": {"password": "hunter2", "email": "demo@example.com"}}, + }, + "providers": [{"api_key": "sk-abcdefghijklmnopqrstuvwxyz"}, {"name": "openai"}], + } + result = redact(payload) + assert result["request"]["headers"]["Authorization"] == REDACTED + assert result["request"]["body"]["user"]["password"] == REDACTED + assert result["request"]["body"]["user"]["email"] == "demo@example.com" + assert result["providers"][0]["api_key"] == REDACTED + assert result["providers"][1]["name"] == "openai" + + +# --------------------------------------------------------------------------- +# Lexical layer — recognisable values under harmless keys +# --------------------------------------------------------------------------- + + +def test_bearer_token_in_a_free_text_message_is_scrubbed() -> None: + text = "upstream rejected the credential Bearer eyJhbGciOiJIUzI1NiJ9.abcdefgh.ijklmnop" + scrubbed = redact_text(text) + assert "eyJhbGciOiJIUzI1NiJ9" not in scrubbed + assert "Bearer" in scrubbed # the scheme stays, it is not the secret + + +def test_authorization_header_form_is_scrubbed_once_not_twice() -> None: + """``Authorization: Bearer `` is a *sensitive key*, so the whole value + goes — and it must not leave a doubled ``[REDACTED] [REDACTED]`` behind.""" + text = "request failed: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefgh.ijklmnop" + scrubbed = redact_text(text) + assert "eyJhbGciOiJIUzI1NiJ9" not in scrubbed + assert scrubbed.count(REDACTED) == 1 + + +def test_jwt_anywhere_in_a_message_is_scrubbed() -> None: + token = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIn0" + ".dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk" + ) + scrubbed = redact_text(f"token decode failed for {token} at step 3") + assert token not in scrubbed + assert "at step 3" in scrubbed + + +def test_url_credentials_are_stripped_but_host_is_kept() -> None: + scrubbed = redact_text("could not connect to postgresql://agy:s3cr3t@db.internal:5432/agy") + assert "s3cr3t" not in scrubbed + assert "db.internal" in scrubbed # the useful part survives + + +def test_provider_key_shapes_are_scrubbed() -> None: + for secret in ( + "sk-abcdefghijklmnopqrstuvwxyz012345", + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "AKIAIOSFODNN7EXAMPLE", + "xoxb-1234567890-abcdefghijkl", + ): + assert secret not in redact_text(f"provider call failed: {secret}") + + +def test_pem_private_key_block_is_scrubbed() -> None: + pem = ( + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIEowIBAAKCAQEA1234567890abcdefghijklmnop\n" + "-----END RSA PRIVATE KEY-----" + ) + scrubbed = redact_text(f"failed to load key:\n{pem}") + assert "MIIEowIBAAKCAQEA" not in scrubbed + assert "REDACTED_PRIVATE_KEY" in scrubbed + + +def test_inline_key_value_pair_in_a_larger_string() -> None: + scrubbed = redact_text("webhook rejected: password=hunter2&retries=3") + assert "hunter2" not in scrubbed + assert "retries=3" in scrubbed + + +# --------------------------------------------------------------------------- +# Third net — configured secrets matched by value +# --------------------------------------------------------------------------- + + +def test_the_configured_jwt_secret_is_scrubbed_by_value() -> None: + """The strongest guarantee: a real configured secret never reaches storage. + + The test suite sets a 32+ byte ``JWT_SECRET``; whatever it is, it must not + survive redaction even when it appears with no key and no recognisable shape. + """ + from app.core.config import settings + + secret = settings.jwt_secret + scrubbed = redact_text(f"signature check failed comparing against {secret} directly") + assert secret not in scrubbed + + +# --------------------------------------------------------------------------- +# Robustness — the redactor must never break a handler +# --------------------------------------------------------------------------- + + +def test_headers_are_scrubbed_and_lowercased() -> None: + result = redact_headers( + {"Authorization": "Bearer abcdefghijkl", "User-Agent": "curl/8.0", "Cookie": "sid=xyz"} + ) + assert result["authorization"] == REDACTED + assert result["cookie"] == REDACTED + assert result["user-agent"] == "curl/8.0" + + +def test_bytes_become_a_length_not_a_body() -> None: + assert redact(b"\x00\x01\x02\x03") == "<4 bytes>" + + +def test_scalars_pass_through_untouched() -> None: + assert redact(42) == 42 + assert redact(1.5) == 1.5 + assert redact(True) is True + assert redact(None) is None + + +def test_empty_inputs_are_handled() -> None: + assert redact_mapping(None) == {} + assert redact_headers(None) == {} + assert redact_text("") == "" + + +def test_very_long_input_is_bounded() -> None: + """A 5 MB blob must not be handed to the regex engine whole.""" + scrubbed = redact_text("x" * 5_000_000) + assert len(scrubbed) < 100_000 + assert "truncated" in scrubbed + + +def test_non_string_keys_do_not_raise() -> None: + result = redact_mapping({1: "a", (2, 3): "b", None: "c"}) + assert result["1"] == "a" + assert result["None"] == "c" diff --git a/apps/api/tests/test_seed.py b/apps/api/tests/test_seed.py new file mode 100644 index 0000000..0e7644b --- /dev/null +++ b/apps/api/tests/test_seed.py @@ -0,0 +1,219 @@ +"""§17 — the seed produces a stack a developer can actually log into. + +This file exists because of two real bugs, both found by trying to *use* a +seeded install rather than by reading the code: + +1. The seed created ``demo@agy.local`` by writing straight to the database, + bypassing request validation — while the login endpoint rejects ``.local`` as + a special-use TLD. The account existed and was unreachable. +2. Even the account that *could* log in belonged to no organization, so every + screen rendered empty and the demo looked broken. + +Neither was caught by the rest of the suite, because every other test signs up a +fresh user at ``@example.com`` and creates its own organization. The seed was +simply never exercised end to end. These tests close that hole. +""" + +from __future__ import annotations + +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy import func, select + +from tests.conftest import auth_header + + +@pytest_asyncio.fixture +async def seeded_install(monkeypatch, engine, session_factory): + """Run the real ``app.seed`` against the in-memory test database. + + ``app.seed`` opens its own engine and session factory, so both module-level + names are swapped for the test ones. The seed code itself is untouched — + testing a copy of it would prove nothing. + """ + import app.seed as seed_module + + monkeypatch.setattr(seed_module, "engine", engine) + monkeypatch.setattr(seed_module, "SessionFactory", session_factory) + await seed_module.seed() + return seed_module + + +async def test_seeded_demo_user_can_log_in(client: AsyncClient, seeded_install): + """The credentials the seed prints must actually work.""" + response = await client.post( + "/api/auth/login", + json={ + "email": seeded_install.DEMO_EMAIL, + "password": seeded_install.DEMO_PASSWORD, + }, + ) + assert response.status_code == 200, ( + f"the seeded demo account cannot log in: {response.text}" + ) + assert response.json()["access_token"] + + +async def test_seeded_platform_owner_can_log_in(client: AsyncClient, seeded_install): + response = await client.post( + "/api/auth/login", + json={ + "email": seeded_install.PLATFORM_OWNER_EMAIL, + "password": seeded_install.PLATFORM_OWNER_PASSWORD, + }, + ) + assert response.status_code == 200, response.text + + me = await client.get( + "/api/auth/me", headers=auth_header(response.json()["access_token"]) + ) + assert me.status_code == 200, me.text + assert me.json()["user"]["is_platform_owner"] is True + + +async def test_seeded_accounts_belong_to_the_sample_organization( + client: AsyncClient, seeded_install +): + """A login that lands on an empty dashboard is a broken demo. + + Both seeded accounts must see the sample organization, otherwise every + screen behind the login renders nothing. + """ + for email, password in ( + (seeded_install.DEMO_EMAIL, seeded_install.DEMO_PASSWORD), + (seeded_install.PLATFORM_OWNER_EMAIL, seeded_install.PLATFORM_OWNER_PASSWORD), + ): + login = await client.post("/api/auth/login", json={"email": email, "password": password}) + assert login.status_code == 200, login.text + + me = await client.get( + "/api/auth/me", headers=auth_header(login.json()["access_token"]) + ) + organizations = me.json()["organizations"] + assert organizations, f"{email} belongs to no organization" + assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME + assert organizations[0]["role"] == "owner" + + +async def test_seeded_install_can_use_the_product( + client: AsyncClient, seeded_install +): + """The seed's platform rows are enough to create a project and run a task. + + A seed that satisfies the login test but leaves the catalogue empty would + still fail the moment a developer tried anything real. + """ + login = await client.post( + "/api/auth/login", + json={ + "email": seeded_install.DEMO_EMAIL, + "password": seeded_install.DEMO_PASSWORD, + }, + ) + headers = auth_header(login.json()["access_token"]) + org_id = (await client.get("/api/auth/me", headers=headers)).json()["organizations"][0]["id"] + + # The platform catalogue the seed installs must be visible to a tenant. + templates = await client.get( + f"/api/organizations/{org_id}/agent-templates", headers=headers + ) + assert templates.status_code == 200, templates.text + assert templates.json(), "the seed must install agent templates" + + sandbox_templates = await client.get( + f"/api/organizations/{org_id}/sandbox/templates", headers=headers + ) + assert sandbox_templates.json(), "the seed must install sandbox templates" + + project_id = ( + await client.post( + f"/api/organizations/{org_id}/projects", + json={"name": "پروژهٔ seed"}, + headers=headers, + ) + ).json()["id"] + + template = templates.json()[0] + employee_id = ( + await client.post( + f"/api/organizations/{org_id}/employees", + json={ + "name": "کارمند seed", + "role_key": template["role_key"], + "template_id": template["id"], + }, + headers=headers, + ) + ).json()["id"] + + task_id = ( + await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "کار seed", + "project_id": project_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + ).json()["id"] + + run = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee_id}, + headers=headers, + ) + assert run.status_code in (200, 201), run.text + assert run.json()["status"] == "completed", ( + "a seeded install must be able to route to a model and finish a run" + ) + + +async def test_seed_is_idempotent(client: AsyncClient, seeded_install, session_factory): + """Re-running the seed must not duplicate anything. + + The memberships and the wallet are now created by get-or-create rather than + as a side effect of creating the organization, so this guards the parts that + were easy to double up. + """ + import app.seed as seed_module + from app.infrastructure.db.models import ( + AgentTemplateRow, + CreditWalletRow, + MembershipRow, + OrganizationRow, + UserRow, + ) + + async def counts() -> dict[str, int]: + async with session_factory() as session: + return { + "organizations": int( + (await session.execute(select(func.count()).select_from(OrganizationRow))) + .scalar_one() + ), + "memberships": int( + (await session.execute(select(func.count()).select_from(MembershipRow))) + .scalar_one() + ), + "wallets": int( + (await session.execute(select(func.count()).select_from(CreditWalletRow))) + .scalar_one() + ), + "users": int( + (await session.execute(select(func.count()).select_from(UserRow))) + .scalar_one() + ), + "templates": int( + (await session.execute(select(func.count()).select_from(AgentTemplateRow))) + .scalar_one() + ), + } + + before = await counts() + await seed_module.seed() + after = await counts() + + assert after == before, f"re-seeding changed row counts: {before} -> {after}" + assert after["organizations"] == 1 + assert after["memberships"] == 2, "one per seeded account, and no duplicates" diff --git a/apps/api/tests/test_tenant_isolation.py b/apps/api/tests/test_tenant_isolation.py new file mode 100644 index 0000000..d367ffd --- /dev/null +++ b/apps/api/tests/test_tenant_isolation.py @@ -0,0 +1,136 @@ +"""Multi-tenancy: the single most important guarantee in the product. + +A member of organization A must never be able to read or mutate anything that +belongs to organization B — not by listing, not by guessing an id. +""" + +from __future__ import annotations + +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + + +async def _create_org(client: AsyncClient, token: str, name: str) -> dict: + response = await client.post( + "/api/organizations", json={"name": name}, headers=auth_header(token) + ) + assert response.status_code == 201, response.text + return response.json() + + +async def test_create_organization_makes_caller_the_owner(client: AsyncClient): + token = (await signup(client, "owner-a@example.com"))["access_token"] + org = await _create_org(client, token, "شرکت آلفا") + assert org["role"] == "owner" + assert org["slug"] + assert org["owner_user_id"] + + +async def test_organization_slug_is_unique_across_tenants(client: AsyncClient): + token_a = (await signup(client, "slug-a@example.com"))["access_token"] + token_b = (await signup(client, "slug-b@example.com"))["access_token"] + first = await _create_org(client, token_a, "My Company") + second = await _create_org(client, token_b, "My Company") + assert first["slug"] != second["slug"] + assert second["slug"].startswith(first["slug"]) + + +async def test_list_only_returns_own_organizations(client: AsyncClient): + token_a = (await signup(client, "list-a@example.com"))["access_token"] + token_b = (await signup(client, "list-b@example.com"))["access_token"] + org_a = await _create_org(client, token_a, "شرکت الف") + await _create_org(client, token_b, "شرکت ب") + + listing = await client.get("/api/organizations", headers=auth_header(token_a)) + assert listing.status_code == 200 + ids = [o["id"] for o in listing.json()] + assert ids == [org_a["id"]] + + +async def test_foreign_organization_is_not_readable(client: AsyncClient): + token_a = (await signup(client, "iso-a@example.com"))["access_token"] + token_b = (await signup(client, "iso-b@example.com"))["access_token"] + org_a = await _create_org(client, token_a, "سازمان محرمانه") + + # B knows A's id but has no membership. + response = await client.get(f"/api/organizations/{org_a['id']}", headers=auth_header(token_b)) + assert response.status_code == 404 + assert response.json()["error"]["code"] == "not_found" + + +async def test_foreign_member_list_is_not_readable(client: AsyncClient): + token_a = (await signup(client, "mem-a@example.com"))["access_token"] + token_b = (await signup(client, "mem-b@example.com"))["access_token"] + org_a = await _create_org(client, token_a, "سازمان با اعضا") + + response = await client.get( + f"/api/organizations/{org_a['id']}/members", headers=auth_header(token_b) + ) + assert response.status_code == 404 + + +async def test_foreign_invite_is_rejected(client: AsyncClient): + token_a = (await signup(client, "inv-a@example.com"))["access_token"] + token_b = (await signup(client, "inv-b@example.com"))["access_token"] + org_a = await _create_org(client, token_a, "سازمان دعوت") + + response = await client.post( + f"/api/organizations/{org_a['id']}/invites", + json={"email": "attacker@example.com", "role": "admin"}, + headers=auth_header(token_b), + ) + assert response.status_code == 404 + + +async def test_owner_can_list_members_and_see_themselves(client: AsyncClient): + token = (await signup(client, "solo@example.com", name="تک‌نفره"))["access_token"] + org = await _create_org(client, token, "تک‌نفره") + + response = await client.get( + f"/api/organizations/{org['id']}/members", headers=auth_header(token) + ) + assert response.status_code == 200 + members = response.json() + assert len(members) == 1 + assert members[0]["role"] == "owner" + assert members[0]["email"] == "solo@example.com" + + +async def test_invite_returns_token_only_to_the_inviter(client: AsyncClient): + token = (await signup(client, "inviter@example.com"))["access_token"] + org = await _create_org(client, token, "دعوت‌کننده") + + response = await client.post( + f"/api/organizations/{org['id']}/invites", + json={"email": "newbie@example.com", "role": "member"}, + headers=auth_header(token), + ) + assert response.status_code == 201 + body = response.json() + assert body["invite_token"] + assert body["email"] == "newbie@example.com" + + +async def test_cannot_invite_as_owner(client: AsyncClient): + token = (await signup(client, "noowner@example.com"))["access_token"] + org = await _create_org(client, token, "بدون مالک دوم") + + response = await client.post( + f"/api/organizations/{org['id']}/invites", + json={"email": "wannabe@example.com", "role": "owner"}, + headers=auth_header(token), + ) + assert response.status_code == 422 + + +async def test_audit_log_records_organization_creation(client: AsyncClient): + token = (await signup(client, "audited@example.com"))["access_token"] + org = await _create_org(client, token, "سازمان دارای audit") + + response = await client.get( + f"/api/organizations/{org['id']}/audit", headers=auth_header(token) + ) + assert response.status_code == 200 + actions = [event["action"] for event in response.json()] + assert "organization.create" in actions diff --git a/apps/api/tests/test_workers.py b/apps/api/tests/test_workers.py new file mode 100644 index 0000000..f7da564 --- /dev/null +++ b/apps/api/tests/test_workers.py @@ -0,0 +1,336 @@ +"""§23 — the standalone workers do real work, and do it idempotently. + +The workers are not HTTP requests, so there is no ``get_session`` dependency to +override; they open their own sessions from ``app.workers.base.SessionFactory``. +The autouse fixture below swaps that one name so the workers run against the same +in-memory schema the API fixtures build — which is also the point of the +``app.application.container`` extraction: worker and API compose services +identically, so a worker test is meaningful and not a re-implementation. + +What is asserted here is the part that is easy to get wrong and expensive to get +wrong: a task that nobody clicked "run" on still runs, a billing period is not +billed twice, and an abandoned sandbox session is stopped *and* audited. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest +import pytest_asyncio +from httpx import AsyncClient + +from app.domain.base import utcnow +from app.workers import base as worker_base +from app.workers.ai_runtime import AiRuntimeWorker +from app.workers.billing import BillingWorker +from app.workers.sandbox_gateway import SandboxGatewayWorker +from tests.conftest import auth_header, signup + + +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + return seeded + + +@pytest_asyncio.fixture(autouse=True) +async def _workers_use_the_test_database(monkeypatch, session_factory): + """Point every worker *and the error recorder* at the in-memory database. + + The recorder opens its own session by design, so patching the worker session + factory alone would leave it writing failures into the developer's real + ``agy_web.db``. + """ + import app.infrastructure.error_log as error_log + + monkeypatch.setattr(worker_base, "SessionFactory", session_factory) + monkeypatch.setattr(error_log, "SessionFactory", session_factory) + return session_factory + + +async def _make_org_with_employee(client: AsyncClient, email: str) -> tuple[str, dict, str, str]: + """Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant.""" + token = (await signup(client, email))["access_token"] + headers = auth_header(token) + org_id = ( + await client.post("/api/organizations", json={"name": "سازمان کارگر"}, headers=headers) + ).json()["id"] + project_id = ( + await client.post( + f"/api/organizations/{org_id}/projects", + json={"name": "پروژهٔ کارگر"}, + headers=headers, + ) + ).json()["id"] + template = ( + await client.get(f"/api/organizations/{org_id}/agent-templates", headers=headers) + ).json()[0] + employee_id = ( + await client.post( + f"/api/organizations/{org_id}/employees", + json={ + "name": "کارمند کارگر", + "role_key": template["role_key"], + "template_id": template["id"], + }, + headers=headers, + ) + ).json()["id"] + return org_id, headers, project_id, employee_id + + +# -- ai-runtime -------------------------------------------------------------- # + + +async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient): + """A task created without pressing "run" must still be picked up.""" + org_id, headers, project_id, employee_id = await _make_org_with_employee( + client, "worker-runtime@example.com" + ) + task_id = ( + await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "کار بی‌صاحب", + "project_id": project_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + ).json()["id"] + + # Nothing has run it yet. + detail = await client.get( + f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers + ) + assert detail.json()["status"] in {"new", "triaged"} + assert detail.json()["runs"] == [] + + outcome = await AiRuntimeWorker().drain() + + assert outcome.processed == 1, outcome.summary() + assert outcome.failed == 0, outcome.notes + + detail = await client.get( + f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers + ) + assert detail.json()["status"] == "done" + runs = detail.json()["runs"] + assert len(runs) == 1 + assert runs[0]["status"] == "completed" + + +async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient): + """A closed task must not be re-run — re-running would double-bill.""" + org_id, headers, project_id, employee_id = await _make_org_with_employee( + client, "worker-runtime-twice@example.com" + ) + await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "کار یک‌باره", + "project_id": project_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + + first = await AiRuntimeWorker().drain() + second = await AiRuntimeWorker().drain() + + assert first.processed == 1 + assert second.processed == 0, "a done task is not runnable" + assert second.touched == 0, second.summary() + + +# -- billing-worker ---------------------------------------------------------- # + + +async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient): + """Usage in the period must become exactly one draft invoice.""" + org_id, headers, project_id, employee_id = await _make_org_with_employee( + client, "worker-billing@example.com" + ) + await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "کار قابل صورتحساب", + "project_id": project_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + assert (await AiRuntimeWorker().drain()).processed == 1, "the run must be metered" + + end = utcnow() + outcome = await BillingWorker().roll_up( + period_start=end - timedelta(days=30), period_end=end + ) + + assert outcome.processed == 1, outcome.summary() + assert outcome.failed == 0, outcome.notes + + invoices = await client.get( + f"/api/organizations/{org_id}/billing/invoices", headers=headers + ) + assert invoices.status_code == 200, invoices.text + body = invoices.json() + assert len(body) == 1, body + assert body[0]["status"] == "draft" + assert body[0]["amount"] > 0, "the invoice must carry the ledger's total" + + +async def test_billing_roll_up_is_idempotent(client: AsyncClient): + """Two passes over the same period must not bill twice.""" + org_id, headers, project_id, employee_id = await _make_org_with_employee( + client, "worker-billing-twice@example.com" + ) + await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "کار یک‌بار صورتحساب", + "project_id": project_id, + "assigned_employee_id": employee_id, + }, + headers=headers, + ) + await AiRuntimeWorker().drain() + + end = utcnow() + start = end - timedelta(days=30) + worker = BillingWorker() + + first = await worker.roll_up(period_start=start, period_end=end) + second = await worker.roll_up(period_start=start, period_end=end) + + assert first.processed == 1 + assert second.processed == 0, "the period was already invoiced" + assert second.skipped >= 1 + + invoices = ( + await client.get(f"/api/organizations/{org_id}/billing/invoices", headers=headers) + ).json() + assert len(invoices) == 1, "exactly one invoice for one period" + + +async def test_billing_roll_up_skips_an_organization_with_no_usage(client: AsyncClient): + """No usage means no $0 draft littering the invoice table.""" + org_id, headers, _, _ = await _make_org_with_employee( + client, "worker-billing-empty@example.com" + ) + + end = utcnow() + outcome = await BillingWorker().roll_up( + period_start=end - timedelta(days=30), period_end=end + ) + + assert outcome.processed == 0 + assert outcome.skipped >= 1 + invoices = ( + await client.get(f"/api/organizations/{org_id}/billing/invoices", headers=headers) + ).json() + assert invoices == [] + + +@pytest.mark.parametrize( + "delta", + [timedelta(0), timedelta(minutes=-5), timedelta(minutes=30)], +) +async def test_billing_roll_up_refuses_a_nonsense_period(delta: timedelta): + """A period that is empty, backwards or absurdly short is a bug, not a bill.""" + end = utcnow() + with pytest.raises(ValueError): + await BillingWorker().roll_up(period_start=end - delta, period_end=end) + + +# -- sandbox-gateway --------------------------------------------------------- # + + +async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: AsyncClient): + """An abandoned session must be stopped, and the stop must be auditable.""" + org_id, headers, project_id, _ = await _make_org_with_employee( + client, "worker-gateway@example.com" + ) + template_id = ( + await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers) + ).json()[0]["id"] + workspace_id = ( + await client.post( + f"/api/organizations/{org_id}/sandbox/workspaces", + json={"project_id": project_id, "template_id": template_id}, + headers=headers, + ) + ).json()["id"] + session_id = ( + await client.post( + f"/api/organizations/{org_id}/sandbox/sessions", + json={"workspace_id": workspace_id}, + headers=headers, + ) + ).json()["id"] + + # Backdate the session so it looks abandoned. The reaper will not touch a + # session that was active a moment ago, which is the behaviour we want. + from app.infrastructure.db.models import SandboxSessionRow + + async with worker_base.SessionFactory() as session: + row = await session.get(SandboxSessionRow, session_id) + assert row is not None + row.last_active_at = utcnow() - timedelta(hours=6) + await session.commit() + + outcome = await SandboxGatewayWorker().reap(idle_minutes=120) + + assert outcome.processed == 1, outcome.summary() + assert outcome.failed == 0, outcome.notes + + # The session row itself must now read ``stopped``. + async with worker_base.SessionFactory() as session: + row = await session.get(SandboxSessionRow, session_id) + assert row is not None + assert row.status == "stopped" + assert row.stopped_at is not None + + audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers) + assert audit.status_code == 200, audit.text + reaps = [e for e in audit.json() if e["action"] == "sandbox.reap"] + assert reaps, "the platform stopping a sandbox must be audited" + assert reaps[0]["target_id"] == session_id + + +async def test_sandbox_gateway_leaves_a_fresh_session_alone(client: AsyncClient): + """A session that was just used must not be reaped.""" + org_id, headers, project_id, _ = await _make_org_with_employee( + client, "worker-gateway-fresh@example.com" + ) + template_id = ( + await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers) + ).json()[0]["id"] + workspace_id = ( + await client.post( + f"/api/organizations/{org_id}/sandbox/workspaces", + json={"project_id": project_id, "template_id": template_id}, + headers=headers, + ) + ).json()["id"] + await client.post( + f"/api/organizations/{org_id}/sandbox/sessions", + json={"workspace_id": workspace_id}, + headers=headers, + ) + + outcome = await SandboxGatewayWorker().reap(idle_minutes=120) + + assert outcome.processed == 0, outcome.summary() + assert outcome.skipped == 1 + + +def test_gateway_policy_report_names_no_secret(): + """The startup report must describe the envelope without leaking a key.""" + outcome = SandboxGatewayWorker().policy_report() + assert outcome.notes, "the report must say something" + + lowered = " ".join(outcome.notes).lower() + for forbidden in ("api_key", "token", "secret", "password", "daytona_api", "e2b_api"): + assert forbidden not in lowered, f"{forbidden} leaked into the policy report" + assert "deny_all" in lowered, "the default egress policy must be stated" diff --git a/apps/api/tests/test_workforce.py b/apps/api/tests/test_workforce.py new file mode 100644 index 0000000..6dd3b55 --- /dev/null +++ b/apps/api/tests/test_workforce.py @@ -0,0 +1,379 @@ +"""AI Workforce: employees, runs, tool permissions and approvals. + +The invariants these tests defend: + +* a customer never sees a model, provider or routing *mode* — only a named policy +* tool permission is deny-by-default, so a tool the platform has never heard of + is not silently allowed +* every run leaves a timeline, because that timeline is the audit trail +* approving is a human decision; nothing dangerous executes without it +""" + +from __future__ import annotations + +import json + +import pytest_asyncio +from httpx import AsyncClient + +from tests.conftest import auth_header, signup + + +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + """Every test here needs the platform-level seed rows. + + Agent templates and routing policies are installed by ``app/seed.py`` and are + not owned by any organization, so a test that creates an employee would + otherwise find no template to start from. + """ + return seeded + + +async def _org(client: AsyncClient, token: str, name: str = "شرکت نیروی کار") -> str: + response = await client.post( + "/api/organizations", json={"name": name}, headers=auth_header(token) + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +async def _employee( + client: AsyncClient, token: str, org_id: str, *, name: str = "برنامه‌نویس یک" +) -> dict: + """Create an employee from the first available template.""" + templates = await client.get( + f"/api/organizations/{org_id}/agent-templates", headers=auth_header(token) + ) + assert templates.status_code == 200, templates.text + template = templates.json()[0] + + response = await client.post( + f"/api/organizations/{org_id}/employees", + json={"name": name, "role_key": template["role_key"], "template_id": template["id"]}, + headers=auth_header(token), + ) + assert response.status_code == 201, response.text + return response.json() + + +# --------------------------------------------------------------------------- +# Employees +# --------------------------------------------------------------------------- + + +async def test_creating_an_employee_seeds_a_prompt_and_tool_permissions(client: AsyncClient): + """A template must produce a usable employee, not an empty shell.""" + token = (await signup(client, "wf-seed@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + detail = await client.get( + f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token) + ) + assert detail.status_code == 200, detail.text + body = detail.json() + + # Prompt version 1 exists and carries the template's default prompt. + assert len(body["prompt_versions"]) >= 1 + assert body["prompt_versions"][0]["version"] == 1 + assert body["prompt_versions"][0]["content"].strip() + + # Tool permissions were seeded from the template. + assert isinstance(body["tool_permissions"], list) + assert all("tool_key" in p and "allowed" in p for p in body["tool_permissions"]) + + +async def test_employee_payload_never_leaks_model_identity(client: AsyncClient): + """The customer buys capacity, not a model. Nothing may name one.""" + token = (await signup(client, "wf-secret@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + detail = await client.get( + f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token) + ) + payload = json.dumps(detail.json()).lower() + for forbidden in ("provider", "api_key", "internal_name", "openai", "anthropic"): + assert forbidden not in payload, f"{forbidden} leaked in employee payload" + + +async def test_routing_choices_expose_a_name_but_not_the_mode(client: AsyncClient): + """`mode` is an internal routing hint and must not be serialised.""" + token = (await signup(client, "wf-routing@example.com"))["access_token"] + org_id = await _org(client, token) + + response = await client.get( + f"/api/organizations/{org_id}/routing-choices", headers=auth_header(token) + ) + assert response.status_code == 200, response.text + choices = response.json() + assert choices, "seed data should provide routing policies" + + for choice in choices: + assert set(choice) <= {"id", "name", "description"}, choice + assert "mode" not in choice + + +async def test_prompt_version_is_appended_with_an_incrementing_number(client: AsyncClient): + token = (await signup(client, "wf-prompt@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + for content in ("نسخهٔ دوم پرامپت", "نسخهٔ سوم پرامپت"): + response = await client.post( + f"/api/organizations/{org_id}/employees/{employee['id']}/prompt-versions", + json={"content": content}, + headers=auth_header(token), + ) + assert response.status_code in (200, 201), response.text + + detail = await client.get( + f"/api/organizations/{org_id}/employees/{employee['id']}", headers=auth_header(token) + ) + versions = sorted(v["version"] for v in detail.json()["prompt_versions"]) + assert versions == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# Tool permissions — deny by default +# --------------------------------------------------------------------------- + + +async def test_unknown_tool_is_denied_by_default(client: AsyncClient): + """The platform must fail closed for a tool it has never heard of. + + A permission row for a tool that does not exist would be a silent no-op that + hides a typo, so the request is rejected outright (422) rather than stored. + """ + token = (await signup(client, "wf-tools@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + # Explicitly grant a tool that is not in the catalog. + response = await client.put( + f"/api/organizations/{org_id}/employees/{employee['id']}/tools/evil.exfiltrate", + json={"allowed": False}, + headers=auth_header(token), + ) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "validation_error" + + # And nothing was persisted: the employee's permission set stays clean. + detail = await client.get( + f"/api/organizations/{org_id}/employees/{employee['id']}", + headers=auth_header(token), + ) + assert detail.status_code == 200, detail.text + granted = {p["tool_key"] for p in detail.json()["tool_permissions"] if p["allowed"]} + assert "evil.exfiltrate" not in granted, "an unknown tool must never be granted" + + +async def test_tool_permission_can_be_toggled(client: AsyncClient): + token = (await signup(client, "wf-toggle@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + base = f"/api/organizations/{org_id}/employees/{employee['id']}/tools" + + granted = await client.put( + f"{base}/knowledge.search", json={"allowed": True}, headers=auth_header(token) + ) + assert granted.status_code == 200, granted.text + assert granted.json()["allowed"] is True + + revoked = await client.put( + f"{base}/knowledge.search", json={"allowed": False}, headers=auth_header(token) + ) + assert revoked.status_code == 200, revoked.text + assert revoked.json()["allowed"] is False + + +async def test_tools_catalog_requires_no_secrets(client: AsyncClient): + """The catalog describes capabilities; it must not carry endpoints or keys.""" + token = (await signup(client, "wf-catalog@example.com"))["access_token"] + org_id = await _org(client, token) + + response = await client.get( + f"/api/organizations/{org_id}/tools", headers=auth_header(token) + ) + assert response.status_code == 200, response.text + payload = json.dumps(response.json()).lower() + for forbidden in ("api_key", "secret", "token", "password"): + assert forbidden not in payload + + +# --------------------------------------------------------------------------- +# Tasks and runs +# --------------------------------------------------------------------------- + + +async def test_starting_a_run_produces_a_timeline(client: AsyncClient): + """Every run must be reconstructable from its steps.""" + token = (await signup(client, "wf-run@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + task = await client.post( + f"/api/organizations/{org_id}/tasks", + json={ + "title": "افزودن تست برای ماژول پرداخت", + "description": "پوشش تست واحد برای محاسبهٔ تخفیف", + "assigned_employee_id": employee["id"], + }, + headers=auth_header(token), + ) + assert task.status_code == 201, task.text + task_id = task.json()["id"] + + run = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee["id"]}, + headers=auth_header(token), + ) + assert run.status_code in (200, 201), run.text + run_body = run.json() + + assert run_body["task_id"] == task_id + assert run_body["status"] in {"queued", "planning", "running", "completed"} + assert len(run_body["steps"]) >= 1, "a run must record at least one step" + assert [s["index"] for s in run_body["steps"]] == sorted( + s["index"] for s in run_body["steps"] + ) + + +async def test_run_payload_never_leaks_model_identity(client: AsyncClient): + token = (await signup(client, "wf-runsecret@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + task = await client.post( + f"/api/organizations/{org_id}/tasks", + json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]}, + headers=auth_header(token), + ) + task_id = task.json()["id"] + run = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee["id"]}, + headers=auth_header(token), + ) + + payload = json.dumps(run.json()).lower() + for forbidden in ("provider", "api_key", "internal_name", "model"): + assert forbidden not in payload, f"{forbidden} leaked in run payload" + + +async def test_task_detail_lists_its_runs(client: AsyncClient): + token = (await signup(client, "wf-taskdetail@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + task = await client.post( + f"/api/organizations/{org_id}/tasks", + json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]}, + headers=auth_header(token), + ) + task_id = task.json()["id"] + + run = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee["id"]}, + headers=auth_header(token), + ) + assert run.status_code == 201, run.text + + detail = await client.get( + f"/api/organizations/{org_id}/tasks/{task_id}", headers=auth_header(token) + ) + assert detail.status_code == 200, detail.text + runs = detail.json()["runs"] + assert len(runs) == 1 + assert runs[0]["id"] == run.json()["id"] + assert runs[0]["task_id"] == task_id + + +async def test_a_closed_task_cannot_be_run_again(client: AsyncClient): + """A successful run closes its task; re-running it must be refused, not + silently create a second run against a task that is already done.""" + token = (await signup(client, "wf-closedtask@example.com"))["access_token"] + org_id = await _org(client, token) + employee = await _employee(client, token, org_id) + + task = await client.post( + f"/api/organizations/{org_id}/tasks", + json={"title": "کار بسته‌شده", "assigned_employee_id": employee["id"]}, + headers=auth_header(token), + ) + task_id = task.json()["id"] + + first = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee["id"]}, + headers=auth_header(token), + ) + assert first.status_code == 201, first.text + assert first.json()["status"] == "completed", first.text + + second = await client.post( + f"/api/organizations/{org_id}/tasks/{task_id}/runs", + json={"employee_id": employee["id"]}, + headers=auth_header(token), + ) + assert second.status_code == 409, second.text + + +# --------------------------------------------------------------------------- +# Tenant isolation +# --------------------------------------------------------------------------- + + +async def test_employee_of_another_tenant_is_not_readable(client: AsyncClient): + token_a = (await signup(client, "wf-iso-a@example.com"))["access_token"] + token_b = (await signup(client, "wf-iso-b@example.com"))["access_token"] + org_a = await _org(client, token_a, "سازمان الف") + employee = await _employee(client, token_a, org_a) + + # B is authenticated and knows the employee id, but has no membership in A. + response = await client.get( + f"/api/organizations/{org_a}/employees/{employee['id']}", headers=auth_header(token_b) + ) + # 404, not 403: the API must not confirm that the employee exists. + assert response.status_code == 404, response.text + + +async def test_task_of_another_tenant_is_not_readable(client: AsyncClient): + token_a = (await signup(client, "wf-tiso-a@example.com"))["access_token"] + token_b = (await signup(client, "wf-tiso-b@example.com"))["access_token"] + org_a = await _org(client, token_a, "سازمان ب") + + task = await client.post( + f"/api/organizations/{org_a}/tasks", + json={"title": "کار محرمانهٔ الف"}, + headers=auth_header(token_a), + ) + task_id = task.json()["id"] + + response = await client.get( + f"/api/organizations/{org_a}/tasks/{task_id}", headers=auth_header(token_b) + ) + assert response.status_code == 404, response.text + + +async def test_pending_approvals_are_org_scoped(client: AsyncClient): + """A tenant must never see another tenant's approval queue.""" + token_a = (await signup(client, "wf-appr-a@example.com"))["access_token"] + token_b = (await signup(client, "wf-appr-b@example.com"))["access_token"] + org_a = await _org(client, token_a, "سازمان پ") + org_b = await _org(client, token_b, "سازمان ت") + + response = await client.get( + f"/api/organizations/{org_b}/approvals/pending", headers=auth_header(token_a) + ) + # Not a member of B at all. + assert response.status_code == 404, response.text + + own = await client.get( + f"/api/organizations/{org_a}/approvals/pending", headers=auth_header(token_a) + ) + assert own.status_code == 200, own.text + assert own.json() == [] diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..2989467 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-html-link-for-pages": "off" + }, + "ignorePatterns": ["node_modules", ".next", "vitest.config.ts"] +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..bad8e31 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,11 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + // Workspace packages ship TypeScript source, so Next must transpile them. + transpilePackages: ["@agy/ui", "@agy/contracts"], + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000", + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3698ea2 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@agy/web", + "version": "0.1.0", + "private": true, + "description": "AGY Web frontend — Next.js App Router, RTL, dark-first.", + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"" + }, + "dependencies": { + "@agy/contracts": "workspace:*", + "@agy/ui": "workspace:*", + "next": "15.1.6", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.10.7", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "15.1.6", + "jsdom": "^26.0.0", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/(app)/admin/errors/page.tsx b/apps/web/src/app/(app)/admin/errors/page.tsx new file mode 100644 index 0000000..dc83c43 --- /dev/null +++ b/apps/web/src/app/(app)/admin/errors/page.tsx @@ -0,0 +1,557 @@ +"use client"; + +import * as React from "react"; +import type { + ErrorEvent, + ErrorGroup, + ErrorSeverity, + ErrorSource, + ErrorStatusFilter, + ErrorSummary, +} from "@agy/contracts"; +import { + Badge, + Button, + Card, + Drawer, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, + Stat, + Table, + Tabs, +} from "@agy/ui"; +import type { TableColumn } from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; + +/** + * Platform-owner error console. + * + * Two views over the same table. **Groups** answers "what is broken, and how + * often?" — one row per distinct bug, however many times it fired. **Occurrences** + * answers "what exactly happened on this request?" — the full record, including + * the traceback and the request id. + * + * The split matters: a bug that fired four thousand times must not push every + * other bug off the screen, and a bug that fired once must still be diagnosable + * without reproducing it. + */ + +const SEVERITY_TONE: Record = { + warning: "warning", + error: "danger", + critical: "danger", +}; + +const SEVERITY_LABEL: Record = { + warning: "هشدار", + error: "خطا", + critical: "بحرانی", +}; + +const SOURCE_LABEL: Record = { + http: "درخواست", + worker: "کارگر پس‌زمینه", + startup: "راه‌اندازی", +}; + +const STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: "open", label: "باز" }, + { value: "resolved", label: "بسته‌شده" }, + { value: "", label: "همه" }, +]; + +const SEVERITY_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "همه" }, + { value: "critical", label: "بحرانی" }, + { value: "error", label: "خطا" }, + { value: "warning", label: "هشدار" }, +]; + +const SOURCE_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "همه" }, + { value: "http", label: "درخواست" }, + { value: "worker", label: "کارگر پس‌زمینه" }, + { value: "startup", label: "راه‌اندازی" }, +]; + +function formatTime(value: string | null): string { + if (!value) return "—"; + return new Date(value).toLocaleString("fa-IR"); +} + +export default function AdminErrorsPage() { + const [tab, setTab] = React.useState<"groups" | "occurrences">("groups"); + const [status, setStatus] = React.useState("open"); + const [severity, setSeverity] = React.useState(""); + const [source, setSource] = React.useState(""); + const [search, setSearch] = React.useState(""); + const [fingerprint, setFingerprint] = React.useState(null); + + const [groups, setGroups] = React.useState([]); + const [events, setEvents] = React.useState([]); + const [total, setTotal] = React.useState(0); + const [summary, setSummary] = React.useState(null); + const [selected, setSelected] = React.useState(null); + + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(null); + const [notice, setNotice] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const query = { + status: status || undefined, + severity: severity || undefined, + source: source || undefined, + search: search || undefined, + fingerprint: fingerprint || undefined, + }; + const [nextGroups, nextEvents, nextSummary] = await Promise.all([ + api.adminErrorGroups(query), + api.adminErrors({ ...query, limit: 100 }), + api.adminErrorSummary(), + ]); + setGroups(nextGroups); + setEvents(nextEvents.items); + setTotal(nextEvents.total); + setSummary(nextSummary); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت خطاها ناموفق بود."); + } finally { + setLoading(false); + } + }, [status, severity, source, search, fingerprint]); + + React.useEffect(() => { + void load(); + }, [load]); + + const openGroup = React.useCallback((group: ErrorGroup) => { + setFingerprint(group.fingerprint); + setTab("occurrences"); + }, []); + + const clearGroup = React.useCallback(() => { + setFingerprint(null); + setTab("groups"); + }, []); + + const resolveGroup = React.useCallback( + async (group: ErrorGroup) => { + setBusy(group.fingerprint); + setNotice(null); + try { + const result = await api.adminResolveErrorGroup( + group.fingerprint, + "بسته‌شده از کنسول خطا", + ); + setNotice(`${result.resolved} رخداد بسته شد.`); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "بستن خطا ناموفق بود."); + } finally { + setBusy(null); + } + }, + [load], + ); + + const reopenGroup = React.useCallback( + async (group: ErrorGroup) => { + setBusy(group.fingerprint); + setNotice(null); + try { + const result = await api.adminReopenErrorGroup(group.fingerprint); + setNotice(`${result.resolved} رخداد بازگشایی شد.`); + await load(); + } catch (err) { + setNotice(err instanceof ApiError ? err.message : "بازگشایی خطا ناموفق بود."); + } finally { + setBusy(null); + } + }, + [load], + ); + + const groupColumns: Array> = [ + { + key: "severity", + header: "شدت", + render: (group) => ( + + {SEVERITY_LABEL[group.severity] ?? group.severity} + + ), + }, + { + key: "what", + header: "خطا", + render: (group) => ( +
+ {group.sample_message || group.kind} + + {group.kind} + {group.error_type ? ` · ${group.error_type}` : ""} + {group.path ? ( + <> + {" · "} + {group.path} + + ) : null} + +
+ ), + }, + { + key: "source", + header: "منبع", + render: (group) => ( + {SOURCE_LABEL[group.source] ?? group.source} + ), + }, + { + key: "count", + header: "رخداد", + align: "center", + render: (group) => ( +
+ {group.occurrences} + {group.open_count > 0 ? ( + {group.open_count} باز + ) : ( + بسته + )} +
+ ), + }, + { + key: "last_seen", + header: "آخرین بار", + render: (group) => ( + {formatTime(group.last_seen)} + ), + }, + { + key: "actions", + header: "عملیات", + align: "end", + render: (group) => ( +
+ + {group.open_count > 0 ? ( + + ) : ( + + )} +
+ ), + }, + ]; + + const eventColumns: Array> = [ + { + key: "severity", + header: "شدت", + render: (event) => ( + + {SEVERITY_LABEL[event.severity] ?? event.severity} + + ), + }, + { + key: "when", + header: "زمان", + render: (event) => ( + {formatTime(event.created_at)} + ), + }, + { + key: "request", + header: "درخواست", + render: (event) => ( +
+ {event.method || event.path ? ( + + {event.method} {event.path} + + ) : ( + + {SOURCE_LABEL[event.source] ?? event.source} + + )} + + {event.error_type || event.kind} + {event.status_code ? ` · ${event.status_code}` : ""} + +
+ ), + }, + { + key: "message", + header: "پیام", + render: (event) => ( + {event.message} + ), + }, + { + key: "resolved", + header: "وضعیت", + align: "center", + render: (event) => + event.resolved_at ? ( + بسته + ) : ( + باز + ), + }, + { + key: "actions", + header: "", + align: "end", + render: (event) => ( + + ), + }, + ]; + + const tiles = summary?.open_by_severity ?? {}; + + return ( +
+
+

کنسول خطا

+

+ هر خطایی که سامانه ثبت کرده است، با جزئیات کامل: مسیر درخواست، شناسهٔ درخواست، + سازمان، کاربر و ردِّ کامل خطا. خطاهای یکسان بر اساس اثر انگشت در یک ردیف گروه‌بندی + می‌شوند. +

+
+ +
+ 0 ? "danger" : "neutral"} + /> + 0 ? "danger" : "neutral"} /> + 0 ? "danger" : "neutral"} /> + +
+ + {notice && ( + +

{notice}

+
+ )} + + {fingerprint && ( + +
+

+ فقط رخدادهای این خطا نمایش داده می‌شود:{" "} + {fingerprint} +

+ +
+
+ )} + + +
+ setSeverity(event.target.value as ErrorSeverity | "")} + className="w-32" + /> + setProviderId(e.target.value)} + options={ + providers.length === 0 + ? [{ value: "", label: "ارائه‌دهنده‌ای وجود ندارد" }] + : providers.map((p) => ({ value: p.id, label: p.name })) + } + /> + setInternalName(e.target.value)} + placeholder="gpt-4o-mini" + className="ltr-inline" + /> + setDisplayName(e.target.value)} + placeholder="مدل سریع و ارزان" + /> +
+ +
+ setInputPrice(e.target.value)} + placeholder="0.15" + className="ltr-inline" + /> + setOutputPrice(e.target.value)} + placeholder="0.60" + className="ltr-inline" + /> +
+ +
+

توانایی‌ها

+
+ {CAPABILITIES.map((cap) => ( + toggleCap(cap)} + className="ltr-inline" + /> + ))} +
+
+ +
+ +
+ + {formError && ( +
+ +
+ )} +
+ + {loading ? ( + + ) : error ? ( + + ) : models.length === 0 ? ( + + ) : ( + + فهرست مدل‌ها +
+ + + + + + + + + + + + + {models.map((model) => ( + + + + + + + + + ))} + +
نام داخلیارائه‌دهندهتوانایی‌هاورودیخروجیوضعیت
+ {model.internal_name} +

{model.display_name_for_admin}

+
+ {providerName(model.provider_id)} + +
+ {model.capabilities.map((cap) => ( + + {cap} + + ))} +
+
+ {model.input_price_per_1m} + + {model.output_price_per_1m} + + + {model.is_active ? "فعال" : "غیرفعال"} + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/admin/organizations/page.tsx b/apps/web/src/app/(app)/admin/organizations/page.tsx new file mode 100644 index 0000000..ee7780f --- /dev/null +++ b/apps/web/src/app/(app)/admin/organizations/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import * as React from "react"; +import type { AdminOrganization, AdminUsageRow } from "@agy/contracts"; +import { Badge, Card, CardTitle, EmptyState, ErrorState, LoadingState, Stat } from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; + +export default function AdminOrganizationsPage() { + const [organizations, setOrganizations] = React.useState([]); + const [usage, setUsage] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextOrganizations, nextUsage] = await Promise.all([ + api.adminOrganizations(), + api.adminUsage(), + ]); + setOrganizations(nextOrganizations); + setUsage(nextUsage); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت سازمان‌ها ناموفق بود."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error) return ; + + const usageFor = (orgId: string) => usage.find((row) => row.organization_id === orgId); + const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0); + + return ( +
+
+

سازمان‌ها

+

+ نمای سراسری مالک سامانه بر همهٔ مستأجرها. این صفحه برای مشتریان قابل دسترسی نیست. +

+
+ +
+ + sum + org.member_count, 0)} + /> + +
+ + {organizations.length === 0 ? ( + + ) : ( + + فهرست سازمان‌ها +
+ + + + + + + + + + + {organizations.map((org) => { + const row = usageFor(org.id); + return ( + + + + + + + ); + })} + +
نامشناسهٔ یکتااعضامصرف
{org.name}{org.slug} + {org.member_count} + + {row ? row.total_price.toFixed(2) : "0.00"} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/admin/page.tsx b/apps/web/src/app/(app)/admin/page.tsx new file mode 100644 index 0000000..4a3f79a --- /dev/null +++ b/apps/web/src/app/(app)/admin/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import * as React from "react"; +import type { AdminOrganization, AdminUsageRow, ReadinessResponse } from "@agy/contracts"; +import { Badge, Card, CardHint, CardTitle, ErrorState, LoadingState, Stat } from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; + +/** + * Platform owner overview. + * + * Everything on this page is invisible to normal customers by design — the whole + * point of the AI Control Plane is that model identity never reaches a tenant. + */ +export default function AdminOverviewPage() { + const [readiness, setReadiness] = React.useState(null); + const [organizations, setOrganizations] = React.useState([]); + const [usage, setUsage] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextReadiness, nextOrganizations, nextUsage] = await Promise.all([ + api.readiness(), + api.adminOrganizations(), + api.adminUsage(), + ]); + setReadiness(nextReadiness); + setOrganizations(nextOrganizations); + setUsage(nextUsage); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت وضعیت ناموفق بود."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error) return ; + + const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0); + + return ( +
+
+

کنسول مالک سامانه

+

+ مدیریت ارائه‌دهنده‌ها، مدل‌ها، سیاست مسیریابی و تعرفه‌ها. +

+
+ +
+ + s + o.member_count, 0)} /> + +
+ + + آداپتورهای فعال + + این مقادیر از محیط خوانده می‌شوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز + متصل نشده است. + +
+ {Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => ( + + {key}: {value} + + ))} +
+
+ +
+ {[ + { + title: "ارائه‌دهنده‌ها و کلیدها", + hint: "ساخت ارائه‌دهنده و ذخیرهٔ کلید رمزنگاری‌شده. کلید خام هرگز نمایش داده نمی‌شود.", + path: "/admin/providers", + }, + { + title: "مدل‌ها", + hint: "نام داخلی، توانایی‌ها و قیمت ورودی/خروجی — فقط برای مالک سامانه.", + path: "/admin/models", + }, + { + title: "سیاست مسیریابی", + hint: "حالت‌های fast / cheap / balanced / reasoning / vision / coding و اجرای آزمایشی.", + path: "/admin/routing", + }, + { + title: "تعرفه‌ها", + hint: "قیمت مشتری و برآورد هزینهٔ داخلی به تفکیک واحد مصرف.", + path: "/admin/rate-cards", + }, + { + title: "سازمان‌ها", + hint: "فهرست همهٔ سازمان‌ها، تعداد اعضا و مصرف تجمیعی هرکدام.", + path: "/admin/organizations", + }, + ].map((item) => ( + + + {item.title} + {item.hint} + + + ))} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/admin/providers/page.tsx b/apps/web/src/app/(app)/admin/providers/page.tsx new file mode 100644 index 0000000..4e87962 --- /dev/null +++ b/apps/web/src/app/(app)/admin/providers/page.tsx @@ -0,0 +1,268 @@ +"use client"; + +import * as React from "react"; +import type { AdminCredential, AdminProvider, ProviderType } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; + +const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [ + { value: "mock", label: "آزمایشی (mock)" }, + { value: "openai_compatible", label: "سازگار با OpenAI" }, + { value: "anthropic", label: "Anthropic" }, + { value: "gemini", label: "Gemini" }, + { value: "openrouter", label: "OpenRouter" }, +]; + +export default function AdminProvidersPage() { + const [providers, setProviders] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [name, setName] = React.useState(""); + const [type, setType] = React.useState("openai_compatible"); + const [baseUrl, setBaseUrl] = React.useState(""); + const [creating, setCreating] = React.useState(false); + const [formError, setFormError] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setProviders(await api.adminProviders()); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت ارائه‌دهنده‌ها ناموفق بود."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + const onCreate = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + setCreating(true); + try { + const created = await api.adminCreateProvider({ + name: name.trim(), + type, + base_url: baseUrl.trim(), + }); + setProviders((current) => [created, ...current]); + setName(""); + setBaseUrl(""); + } catch (err) { + setFormError(err instanceof ApiError ? err.message : "ساخت ارائه‌دهنده ناموفق بود."); + } finally { + setCreating(false); + } + }; + + return ( +
+
+

ارائه‌دهنده‌ها

+

+ هر ارائه‌دهنده می‌تواند چند کلید داشته باشد. کلیدها رمزنگاری‌شده ذخیره می‌شوند و فقط + به‌صورت ماسک‌شده برگردانده می‌شوند. +

+
+ + + افزودن ارائه‌دهنده +
+
+ setName(e.target.value)} + placeholder="مثلاً openai-main" + className="ltr-inline" + /> + setUnit(e.target.value as RateUnit)} + options={UNITS} + /> + setName(e.target.value)} + /> + setCustomerPrice(e.target.value)} + placeholder="0.0004" + className="ltr-inline" + /> + setInternalCost(e.target.value)} + placeholder="0.0002" + className="ltr-inline" + /> +
+ setIsActive(e.target.checked)} + /> +
+ +
+ + {formError && ( +
+ +
+ )} +
+ + {loading ? ( + + ) : error ? ( + + ) : cards.length === 0 ? ( + + ) : ( + + تعرفه‌های جاری +
+ + + + + + + + + + + + {cards.map((card) => { + const margin = card.customer_price - card.internal_cost_estimate; + const marginPct = + card.customer_price > 0 ? (margin / card.customer_price) * 100 : 0; + return ( + + + + + + + + ); + })} + +
واحد مصرفقیمت مشتریهزینهٔ داخلیحاشیهوضعیت
{unitLabel(card.unit)} + {card.customer_price} + + {card.internal_cost_estimate} + + = 0 ? "success" : "danger"}> + {marginPct.toFixed(1)}% + + + + {card.is_active ? "فعال" : "غیرفعال"} + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/admin/routing/page.tsx b/apps/web/src/app/(app)/admin/routing/page.tsx new file mode 100644 index 0000000..3f9c4ff --- /dev/null +++ b/apps/web/src/app/(app)/admin/routing/page.tsx @@ -0,0 +1,222 @@ +"use client"; + +import * as React from "react"; +import type { AdminRoutingPolicy, ModelChoice, RoutingMode } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + Checkbox, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, + Stat, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; + +const MODES: { value: RoutingMode; label: string }[] = [ + { value: "fast", label: "سریع (fast)" }, + { value: "cheap", label: "ارزان (cheap)" }, + { value: "balanced", label: "متعادل (balanced)" }, + { value: "reasoning", label: "استدلالی (reasoning)" }, + { value: "vision", label: "تصویری (vision)" }, + { value: "coding", label: "کدنویسی (coding)" }, +]; + +export default function AdminRoutingPage() { + const [policies, setPolicies] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [name, setName] = React.useState(""); + const [mode, setMode] = React.useState("balanced"); + const [description, setDescription] = React.useState(""); + const [isDefault, setIsDefault] = React.useState(false); + const [creating, setCreating] = React.useState(false); + const [formError, setFormError] = React.useState(null); + + const [probeMode, setProbeMode] = React.useState("coding"); + const [choice, setChoice] = React.useState(null); + const [probing, setProbing] = React.useState(false); + const [probeError, setProbeError] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setPolicies(await api.adminRoutingPolicies()); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت سیاست‌ها ناموفق بود."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + const onCreate = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + setCreating(true); + try { + const created = await api.adminCreateRoutingPolicy({ + name: name.trim(), + mode, + description: description.trim(), + is_default: isDefault, + }); + // A new default demotes the previous one server-side; mirror that locally. + setPolicies((current) => [ + created, + ...current.map((p) => (isDefault ? { ...p, is_default: false } : p)), + ]); + setName(""); + setDescription(""); + setIsDefault(false); + } catch (err) { + setFormError(err instanceof ApiError ? err.message : "ساخت سیاست ناموفق بود."); + } finally { + setCreating(false); + } + }; + + const onProbe = async () => { + setProbing(true); + setProbeError(null); + try { + setChoice(await api.adminValidateModelChoice(probeMode)); + } catch (err) { + setChoice(null); + setProbeError(err instanceof ApiError ? err.message : "اجرای آزمایشی ناموفق بود."); + } finally { + setProbing(false); + } + }; + + return ( +
+
+

سیاست مسیریابی

+

+ هر کار با یک «حالت» به موتور مسیریابی می‌رسد؛ سامانه خودش مدل مناسب را انتخاب می‌کند. +

+
+ + + اجرای آزمایشی مسیریابی + + بررسی کنید در هر حالت کدام مدل انتخاب می‌شود و هزینهٔ برآوردی آن چقدر است. هیچ فراخوانی + واقعی به مدل انجام نمی‌شود. + +
+
+ setMode(e.target.value as RoutingMode)} + options={MODES} + /> + setDescription(e.target.value)} + /> +
+ setIsDefault(e.target.checked)} + /> +
+ +
+ + {formError && ( +
+ +
+ )} + + + {loading ? ( + + ) : error ? ( + + ) : policies.length === 0 ? ( + + ) : ( +
+ {policies.map((policy) => ( + +
+

{policy.name}

+
+ {policy.is_default && پیش‌فرض} + + {policy.is_active ? "فعال" : "غیرفعال"} + +
+
+ + {policy.mode} + + {policy.description && ( +

{policy.description}

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/billing/page.tsx b/apps/web/src/app/(app)/billing/page.tsx new file mode 100644 index 0000000..f548168 --- /dev/null +++ b/apps/web/src/app/(app)/billing/page.tsx @@ -0,0 +1,391 @@ +"use client"; + +import * as React from "react"; +import type { + BillingSummary, + Employee, + Invoice, + Payment, + UsageEvent, +} from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + Field, + LoadingState, + Stat, + UsageBar, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions"; +import { useSession } from "@/lib/session"; + +const UNIT_LABELS: Record = { + model_input_tokens: "توکن ورودی", + model_output_tokens: "توکن خروجی", + sandbox_cpu_second: "ثانیهٔ CPU", + sandbox_memory_gb_second: "گیگابایت‌ثانیهٔ حافظه", + storage_gb_day: "گیگابایت‌روز", + tool_call: "فراخوانی ابزار", + premium_employee_run: "اجرای کارمند حرفه‌ای", +}; + +const INVOICE_TONES: Record = { + draft: "neutral", + open: "info", + paid: "success", + void: "neutral", + uncollectible: "danger", +}; + +/** Format a money amount. The platform currency is a single global unit. */ +function money(value: number, currency = ""): string { + const formatted = value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + }); + return currency ? `${formatted} ${currency}` : formatted; +} + +export default function BillingPage() { + const { activeOrganization } = useSession(); + const orgId = activeOrganization?.id ?? null; + + const [summary, setSummary] = React.useState(null); + const [events, setEvents] = React.useState([]); + const [invoices, setInvoices] = React.useState([]); + const [payments, setPayments] = React.useState([]); + const [employees, setEmployees] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [topUp, setTopUp] = React.useState(""); + const [adding, setAdding] = React.useState(false); + const [actionError, setActionError] = React.useState(null); + const [checkoutUrl, setCheckoutUrl] = React.useState(null); + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const [nextSummary, nextEvents, nextInvoices, nextPayments, nextEmployees] = + await Promise.all([ + api.billingSummary(orgId), + api.billingUsageEvents(orgId, 50), + api.billingInvoices(orgId), + api.billingPayments(orgId), + api.listEmployees(orgId), + ]); + setSummary(nextSummary); + setEvents(nextEvents); + setInvoices(nextInvoices); + setPayments(nextPayments); + setEmployees(nextEmployees); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت اطلاعات مالی ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId]); + + React.useEffect(() => { + void load(); + }, [load]); + + const onAddCredit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId) return; + setActionError(null); + const amount = Number(topUp); + if (!Number.isFinite(amount) || amount <= 0) { + setActionError("مبلغ باید عددی بزرگ‌تر از صفر باشد."); + return; + } + setAdding(true); + try { + const wallet = await api.billingAddCredit(orgId, amount); + setSummary((current) => + current ? { ...current, balance: wallet.balance, low_balance: wallet.low_balance } : current, + ); + setTopUp(""); + } catch (err) { + setActionError(err instanceof ApiError ? err.message : "افزایش اعتبار ناموفق بود."); + } finally { + setAdding(false); + } + }; + + const onCheckout = async () => { + if (!orgId) return; + setActionError(null); + const amount = Number(topUp) || 10; + try { + const result = await api.billingCheckout(orgId, amount); + setCheckoutUrl(result.checkout_url); + await load(); + } catch (err) { + setActionError(err instanceof ApiError ? err.message : "ایجاد پرداخت ناموفق بود."); + } + }; + + if (!activeOrganization) { + return ( + + ); + } + + if (loading) return ; + if (error) return ; + + const currency = summary?.currency ?? ""; + const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING); + + return ( +
+
+

مالی و مصرف

+

+ دفتر مصرف داخلی منبع حقیقت است. هر فراخوانی مدل و هر دستور سندباکس یک ردیف مصرف ثبت + می‌کند. +

+
+ + {summary?.low_balance && ( + +
+ اعتبار کم +

+ موجودی اعتبار به آستانهٔ هشدار رسیده است. اجراهای جدید ممکن است متوقف شوند. +

+
+
+ )} + +
+ + + +
+ + {canManage && ( + + افزایش اعتبار + + افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر + پرداخت آنلاین هم استفاده کنید. + +
+
+ setTopUp(e.target.value)} + placeholder="مثلاً 25" + className="ltr-inline" + /> +
+ + +
+ {actionError && ( +
+ +
+ )} + {checkoutUrl && ( +

+ لینک پرداخت:{" "} + + {checkoutUrl} + +

+ )} +
+ )} + + + تفکیک بر اساس واحد مصرف + {!summary || summary.by_unit.length === 0 ? ( +

هنوز مصرفی ثبت نشده است.

+ ) : ( +
+ + + + + + + + + + {summary.by_unit.map((row) => ( + + + + + + ))} + +
واحدمقدارهزینه
+ {UNIT_LABELS[row.unit] ?? row.unit} + + {row.quantity.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + {money(row.total_price, currency)} +
+
+ )} +
+ +
+ + مصرف به تفکیک کارمند + {!summary || Object.keys(summary.by_employee).length === 0 ? ( +

مصرفی به کارمندی نسبت داده نشده است.

+ ) : ( +
    + {Object.entries(summary.by_employee).map(([employeeId, total]) => { + const employee = employees.find((e) => e.id === employeeId); + const share = + (summary.total_spend > 0 ? total / summary.total_spend : 0) * 100; + return ( +
  • +
    + {employee?.name ?? employeeId.slice(0, 8)} + {money(total, currency)} +
    + +
  • + ); + })} +
+ )} +
+ + + صورت‌حساب‌ها + {invoices.length === 0 ? ( +

صورت‌حسابی صادر نشده است.

+ ) : ( +
    + {invoices.map((invoice) => ( +
  • + + {money(invoice.amount, invoice.currency)} + + + {invoice.status} + +
  • + ))} +
+ )} +
+
+ + + آخرین ردیف‌های مصرف + هر ردیف یک واحد قابل صورتحساب است و حذف نمی‌شود. + {events.length === 0 ? ( +

مصرفی ثبت نشده است.

+ ) : ( +
+ + + + + + + + + + + {events.map((event) => ( + + + + + + + ))} + +
واحدمقدارهزینهزمان
+ {UNIT_LABELS[event.unit] ?? event.unit} + + {event.quantity.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + {money(event.total_price, currency)} + + {new Date(event.created_at).toLocaleString("fa-IR")} +
+
+ )} +
+ + {payments.length > 0 && ( + + پرداخت‌ها +
    + {payments.map((payment) => ( +
  • + + {money(payment.amount, payment.currency)} + +
    + + {payment.provider} + + + {payment.status} + +
    +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..baf9d7f --- /dev/null +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import * as React from "react"; +import type { AuditEvent, ReadinessResponse } from "@agy/contracts"; +import { + Badge, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + LoadingState, + Stat, + Timeline, + UsageBar, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +export default function DashboardPage() { + const { activeOrganization, user } = useSession(); + const [readiness, setReadiness] = React.useState(null); + const [audit, setAudit] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const health = await api.readiness(); + setReadiness(health); + if (activeOrganization) { + setAudit(await api.listAudit(activeOrganization.id).catch(() => [])); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت وضعیت ناموفق بود."); + } finally { + setLoading(false); + } + }, [activeOrganization]); + + React.useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error) return ; + + if (!activeOrganization) { + return ( + + ساخت سازمان + + } + /> + ); + } + + const adapters = readiness?.checks.adapters ?? {}; + const databaseOk = readiness?.checks.database === "ok"; + + return ( +
+
+

+ {activeOrganization.name} +

+

+ خوش آمدید{user?.name ? `، ${user.name}` : ""}. وضعیت کلی سازمان در یک نگاه. +

+
+ +
+ + + + +
+ +
+ + مصرف این ماه + سقف مصرف بر اساس پلن سازمان محاسبه می‌شود. +
+ +
+
+ + + وضعیت اتصال‌ها + + مدل و provider از دید شما پنهان است؛ این‌ها فقط وضعیت فنی سرویس‌اند. + +
+ + پایگاه داده: {databaseOk ? "متصل" : "خطا"} + + {Object.entries(adapters).map(([key, value]) => ( + + {key}: {value} + + ))} +
+
+
+ +
+ + رویدادهای اخیر سازمان + هر عملیات حساس در audit log ثبت می‌شود. +
+ {audit.length === 0 ? ( +

رویدادی ثبت نشده است.

+ ) : ( + ({ + id: event.id, + title: event.action, + meta: new Date(event.created_at).toLocaleString("fa-IR"), + }))} + /> + )} +
+
+ + + دامنه‌های پیش‌نمایش + پس از فاز Domain Routing، دامنه‌های تست پروژه‌ها اینجا دیده می‌شوند. +

دامنه‌ای ثبت نشده است.

+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/knowledge/page.tsx b/apps/web/src/app/(app)/knowledge/page.tsx new file mode 100644 index 0000000..6590575 --- /dev/null +++ b/apps/web/src/app/(app)/knowledge/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function KnowledgePage() { + return ( + + ); +} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx new file mode 100644 index 0000000..18fe8a3 --- /dev/null +++ b/apps/web/src/app/(app)/layout.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { LoadingState } from "@agy/ui"; +import { AppShell } from "@/components/AppShell"; +import { useSession } from "@/lib/session"; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + const { ready, user } = useSession(); + const router = useRouter(); + + React.useEffect(() => { + if (ready && !user) router.replace("/login"); + }, [ready, user, router]); + + if (!ready) { + return ( +
+ +
+ ); + } + + if (!user) return null; + + return {children}; +} diff --git a/apps/web/src/app/(app)/manager/page.tsx b/apps/web/src/app/(app)/manager/page.tsx new file mode 100644 index 0000000..a0d9a8e --- /dev/null +++ b/apps/web/src/app/(app)/manager/page.tsx @@ -0,0 +1,321 @@ +"use client"; + +import Link from "next/link"; +import * as React from "react"; +import type { Employee, Message, Task, Thread } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; +import { useSession } from "@/lib/session"; + +/** + * Manager Chat — three columns, matching the plan: conversations, the + * conversation itself, and the work it produced. + */ +export default function ManagerPage() { + const { activeOrganization } = useSession(); + const orgId = activeOrganization?.id ?? null; + + const [threads, setThreads] = React.useState([]); + const [employees, setEmployees] = React.useState([]); + const [activeThreadId, setActiveThreadId] = React.useState(null); + const [messages, setMessages] = React.useState([]); + const [tasks, setTasks] = React.useState([]); + + const [loading, setLoading] = React.useState(true); + const [loadingThread, setLoadingThread] = React.useState(false); + const [error, setError] = React.useState(null); + + const [draft, setDraft] = React.useState(""); + const [sending, setSending] = React.useState(false); + const [sendError, setSendError] = React.useState(null); + + const [creatingTask, setCreatingTask] = React.useState(false); + const [taskDraft, setTaskDraft] = React.useState(""); + const [taskEmployee, setTaskEmployee] = React.useState(""); + const [taskError, setTaskError] = React.useState(null); + + const bottomRef = React.useRef(null); + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const [nextThreads, nextEmployees, nextTasks] = await Promise.all([ + api.listThreads(orgId), + api.listEmployees(orgId), + api.listTasks(orgId), + ]); + setThreads(nextThreads); + setEmployees(nextEmployees); + setTasks(nextTasks); + setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId]); + + React.useEffect(() => { + void load(); + }, [load]); + + const loadThread = React.useCallback( + async (threadId: string) => { + if (!orgId) return; + setLoadingThread(true); + setSendError(null); + try { + const detail = await api.getThread(orgId, threadId); + setMessages(detail.messages); + } catch (err) { + setSendError(err instanceof ApiError ? err.message : "دریافت پیام‌ها ناموفق بود."); + } finally { + setLoadingThread(false); + } + }, + [orgId], + ); + + React.useEffect(() => { + if (activeThreadId) void loadThread(activeThreadId); + else setMessages([]); + }, [activeThreadId, loadThread]); + + React.useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const onCreateThread = async () => { + if (!orgId) return; + try { + const thread = await api.createThread(orgId, { title: "گفتگوی جدید" }); + setThreads((current) => [thread, ...current]); + setActiveThreadId(thread.id); + setMessages([]); + } catch (err) { + setError(err instanceof ApiError ? err.message : "ساخت گفتگو ناموفق بود."); + } + }; + + const onSend = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId || !activeThreadId || !draft.trim()) return; + setSendError(null); + setSending(true); + const content = draft.trim(); + setDraft(""); + try { + const result = await api.postMessage(orgId, activeThreadId, { content }); + setMessages((current) => [...current, result.user_message, result.assistant_message]); + } catch (err) { + setSendError(err instanceof ApiError ? err.message : "ارسال پیام ناموفق بود."); + setDraft(content); + } finally { + setSending(false); + } + }; + + const onCreateTask = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId || !activeThreadId) return; + setTaskError(null); + setCreatingTask(true); + try { + const task = await api.createTask(orgId, { + title: taskDraft.trim(), + description: `از گفتگو: ${messages.filter((m) => m.role === "user").at(-1)?.content.slice(0, 400) ?? ""}`, + thread_id: activeThreadId, + assigned_employee_id: taskEmployee || null, + }); + setTasks((current) => [task, ...current]); + setTaskDraft(""); + } catch (err) { + setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود."); + } finally { + setCreatingTask(false); + } + }; + + if (!activeOrganization) { + return ( + + ); + } + + const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS); + const threadTasks = tasks.filter((t) => t.thread_id === activeThreadId); + + if (loading) return ; + if (error) return ; + + return ( +
+
+
+

گفتگو با مدیر

+

+ درخواست خود را بنویسید؛ مدیر آن را می‌فهمد و در صورت نیاز به یک کار تبدیل می‌کند. +

+
+ +
+ +
+ {/* Column 1 — conversations */} + + گفتگوها + {threads.length === 0 ? ( +

گفتگویی وجود ندارد.

+ ) : ( +
    + {threads.map((thread) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Column 2 — the conversation */} + + پیام‌ها +
+ {loadingThread ? ( + + ) : messages.length === 0 ? ( +

+ هنوز پیامی رد و بدل نشده است. اولین پیام را بنویسید. +

+ ) : ( + messages.map((message) => ( +
+ {message.content} +
+ )) + )} +
+
+ + {sendError && ( +
+ +
+ )} + +
+
+ setDraft(e.target.value)} + placeholder="مثلاً یک صفحهٔ گزارش فروش طراحی کن…" + disabled={!activeThreadId || sending} + /> +
+ +
+ + + {/* Column 3 — the work this conversation produced */} + + کارهای این گفتگو + از گفتگو یک کار بسازید تا کارمند هوش مصنوعی آن را اجرا کند. + + {threadTasks.length === 0 ? ( +

کاری ساخته نشده است.

+ ) : ( +
    + {threadTasks.map((task) => ( +
  • + + {task.title} + + {task.status} + + +
  • + ))} +
+ )} + + {canRun && ( +
+ setTaskDraft(e.target.value)} + placeholder="عنوان کار…" + /> + void onChangeRole(member, e.target.value as Role)} + options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ + value, + label, + }))} + /> +
+ + + ) : ( + {ROLE_LABELS[member.role] ?? member.role} + )} +
+ + ))} + + )} +
+
+ + {canManageMembers && ( + + دعوت عضو جدید + + یک لینک دعوت یک‌بارمصرف ساخته می‌شود. لینک را خودتان برای فرد بفرستید — سامانه + ایمیل ارسال نمی‌کند. + + +
+ setEmail(e.target.value)} + placeholder="name@example.com" + className="ltr-inline" + /> +
+
+ + setApprovalNote((current) => ({ ...current, [call.id]: e.target.value })) + } + placeholder="یادداشت (اختیاری)" + className="h-9 w-full rounded-lg border border-surface-border bg-surface-base px-3 text-xs text-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/60" + /> +
+ + +
+ + )} +
+ ))} +
+ + )} + +
+ + {selectedRun.status} + ) : ( + "—" + ) + } + /> + +
+ + {task.runs.length > 0 && ( + + تاریخچهٔ اجراها +
+ {task.runs.map((run) => ( + + ))} +
+
+ )} + + {!selectedRun ? ( + + ) : ( + <> + +
+
+ خط زمانی اجرا + + هر گذار وضعیت به‌صورت یک گام ثبت می‌شود؛ این خط زمانی منبع حقیقت ردیابی است. + +
+ {canRun && runIsLive && ( + + )} +
+
+ {selectedRun.steps.length === 0 ? ( +

گامی ثبت نشده است.

+ ) : ( + ({ + id: step.id, + title: step.title || step.kind, + meta: new Date(step.created_at).toLocaleString("fa-IR"), + tone: + step.kind === "error" + ? ("danger" as const) + : step.kind === "artifact" + ? ("success" as const) + : ("info" as const), + }))} + /> + )} +
+ {selectedRun.error && ( +
+ +
+ )} +
+ + {selectedRun.tool_calls.length > 0 && ( + + فراخوانی ابزارها + + ابزارهای خطرناک تا زمانی که تأیید نشوند اجرا نمی‌شوند. + +
+ {selectedRun.tool_calls.map((call) => ( +
+ {call.tool_key} +
+ {call.requires_approval && نیازمند تأیید} + + {call.status} + +
+
+ ))} +
+
+ )} + + + خروجی‌ها + {selectedRun.artifacts.length === 0 ? ( +

خروجی‌ای ثبت نشده است.

+ ) : ( +
+ {selectedRun.artifacts.map((artifact) => ( +
+
+ {artifact.name} +
+ + {artifact.kind} + + {artifact.size_bytes} بایت +
+
+
+                      {artifact.content_ref}
+                    
+
+ ))} +
+ )} +
+ + )} + + ); +} diff --git a/apps/web/src/app/(app)/tasks/page.tsx b/apps/web/src/app/(app)/tasks/page.tsx new file mode 100644 index 0000000..f8f29d4 --- /dev/null +++ b/apps/web/src/app/(app)/tasks/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +import Link from "next/link"; +import * as React from "react"; +import type { Employee, Task, TaskStatus } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; +import { useSession } from "@/lib/session"; + +const STATUS_LABELS: Record = { + new: "جدید", + triaged: "دسته‌بندی‌شده", + waiting_approval: "در انتظار تأیید", + running: "در حال اجرا", + done: "انجام‌شده", + failed: "ناموفق", + cancelled: "لغوشده", +}; + +const STATUS_TONES: Record = { + new: "neutral", + triaged: "info", + waiting_approval: "warning", + running: "info", + done: "success", + failed: "danger", + cancelled: "neutral", +}; + +export default function TasksPage() { + const { activeOrganization } = useSession(); + const orgId = activeOrganization?.id ?? null; + + const [tasks, setTasks] = React.useState([]); + const [employees, setEmployees] = React.useState([]); + const [filter, setFilter] = React.useState(""); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [showForm, setShowForm] = React.useState(false); + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [assignedTo, setAssignedTo] = React.useState(""); + const [creating, setCreating] = React.useState(false); + const [formError, setFormError] = React.useState(null); + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const [nextTasks, nextEmployees] = await Promise.all([ + api.listTasks(orgId, filter || undefined), + api.listEmployees(orgId), + ]); + setTasks(nextTasks); + setEmployees(nextEmployees); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId, filter]); + + React.useEffect(() => { + void load(); + }, [load]); + + const onCreate = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId) return; + setFormError(null); + setCreating(true); + try { + const created = await api.createTask(orgId, { + title: title.trim(), + description: description.trim(), + assigned_employee_id: assignedTo || null, + }); + setTasks((current) => [created, ...current]); + setTitle(""); + setDescription(""); + setAssignedTo(""); + setShowForm(false); + } catch (err) { + setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود."); + } finally { + setCreating(false); + } + }; + + if (!activeOrganization) { + return ( + + ); + } + + const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS); + + return ( +
+
+
+

کارها

+

+ هر کار می‌تواند به یک کارمند سپرده شود و در چند اجرا پیش برود. +

+
+ {canRun && ( + + )} +
+ + {showForm && ( + + ساخت کار + +
+ setTitle(e.target.value)} + placeholder="مثلاً افزودن صفحهٔ تسویه‌حساب" + /> + setFilter(e.target.value as TaskStatus | "")} + options={[ + { value: "", label: "همه" }, + ...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })), + ]} + /> +
+
+ + {loading ? ( + + ) : error ? ( + + ) : tasks.length === 0 ? ( + + ) : ( +
+ {tasks.map((task) => { + const employee = employees.find((e) => e.id === task.assigned_employee_id); + return ( + +
+

{task.title}

+ {task.description && ( +

{task.description}

+ )} +

+ {employee ? `مسئول: ${employee.name}` : "بدون مسئول"} +

+
+
+ + {STATUS_LABELS[task.status] ?? task.status} + + + + +
+
+ ); + })} +
+ )} + + + + کارهای در انتظار تأیید ابزارهای خطرناک در صفحهٔ جزئیات همان کار نمایش داده می‌شوند. + + + + ); +} diff --git a/apps/web/src/app/(app)/traces/page.tsx b/apps/web/src/app/(app)/traces/page.tsx new file mode 100644 index 0000000..a495b14 --- /dev/null +++ b/apps/web/src/app/(app)/traces/page.tsx @@ -0,0 +1,371 @@ +"use client"; + +/** + * Cross-cutting trace view. + * + * One place to reconstruct what the platform did: every run across the + * organization, its step timeline, the tools it called, and the audit stream + * beside it. + * + * Two deliberate constraints: + * + * - The task list is capped before fanning out. There is no "list all runs for + * an organization" endpoint, so we read runs through their owning task; an + * uncapped fan-out would turn one page load into hundreds of requests. + * - No model or provider identity is rendered. Those never leave the platform + * owner's console. + */ + +import Link from "next/link"; +import * as React from "react"; +import type { AuditEvent, Run, RunDetail } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + LoadingState, + Stat, + Timeline, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +/** How many tasks to fan out into. Bounds the request count. */ +const TASK_SCAN_LIMIT = 12; + +const RUN_TONES: Record = { + queued: "neutral", + planning: "info", + running: "info", + tool_calling: "info", + sandbox_running: "info", + waiting_approval: "warning", + completed: "success", + failed: "danger", + cancelled: "neutral", + timed_out: "danger", +}; + +const AUDIT_LABELS: Record = { + "auth.login": "ورود کاربر", + "auth.signup": "ثبت‌نام کاربر", + "org.create": "ساخت سازمان", + "org.member_invite": "دعوت عضو", + "org.member_update": "تغییر نقش عضو", + "org.member_remove": "حذف عضو", + "project.create": "ساخت پروژه", + "project.repo_ensure": "ساخت مخزن", + "project.preview_domain_create": "ساخت دامنهٔ تست", + "project.preview_domain_delete": "حذف دامنهٔ تست", + "employee.create": "ساخت کارمند", + "employee.update": "ویرایش کارمند", + "employee.prompt_version": "نسخهٔ پرامپت جدید", + "employee.tool_permission": "تغییر دسترسی ابزار", + "task.create": "ساخت کار", + "run.start": "شروع اجرا", + "run.cancel": "لغو اجرا", + "approval.decide": "تصمیم تأیید", + "billing.add_credit": "افزایش اعتبار", + "billing.checkout": "شروع پرداخت", + "billing.settle": "تسویهٔ پرداخت", + "sandbox.workspace_create": "ساخت محیط ایزوله", + "sandbox.destroy": "حذف محیط ایزوله", + "sandbox.command": "اجرای دستور در sandbox", +}; + +function auditLabel(action: string): string { + return AUDIT_LABELS[action] ?? action; +} + +function formatDuration(run: Run): string { + if (!run.started_at) return "—"; + const end = run.finished_at ? new Date(run.finished_at).getTime() : Date.now(); + const ms = end - new Date(run.started_at).getTime(); + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms / 60_000)}m`; +} + +interface RunRow { + run: Run; + taskId: string; + taskTitle: string; +} + +export default function TracesPage() { + const { activeOrganization } = useSession(); + const orgId = activeOrganization?.id ?? null; + + const [rows, setRows] = React.useState([]); + const [audit, setAudit] = React.useState([]); + const [selected, setSelected] = React.useState(null); + const [selectedId, setSelectedId] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [detailLoading, setDetailLoading] = React.useState(false); + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const [tasks, auditEvents] = await Promise.all([ + api.listTasks(orgId), + api.listAudit(orgId), + ]); + setAudit(auditEvents); + + const recent = tasks.slice(0, TASK_SCAN_LIMIT); + const details = await Promise.all( + recent.map(async (task) => { + try { + return { task, detail: await api.getTask(orgId, task.id) }; + } catch { + // One unreadable task must not blank the whole trace view. + return { task, detail: null }; + } + }), + ); + + const collected: RunRow[] = []; + for (const { task, detail } of details) { + for (const run of detail?.runs ?? []) { + collected.push({ run, taskId: task.id, taskTitle: task.title }); + } + } + collected.sort( + (a, b) => new Date(b.run.created_at).getTime() - new Date(a.run.created_at).getTime(), + ); + setRows(collected); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت رد‌یابی ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId]); + + React.useEffect(() => { + void load(); + }, [load]); + + const onSelect = async (runId: string) => { + if (!orgId) return; + setSelectedId(runId); + setDetailLoading(true); + try { + setSelected(await api.getRun(orgId, runId)); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود."); + } finally { + setDetailLoading(false); + } + }; + + if (!activeOrganization) { + return ( + + ); + } + + if (loading) return ; + if (error) return ; + + const failed = rows.filter((r) => r.run.status === "failed" || r.run.status === "timed_out").length; + + return ( +
+
+

ردیابی و رویدادها

+

+ هر اجرا با خط زمانی گام‌ها، ابزارهای فراخوانی‌شده و رویدادهای audit. هویت مدل و + ارائه‌دهنده در این نما نمایش داده نمی‌شود. +

+
+ +
+ + 0 ? "danger" : "neutral"} /> + +
+ +
+
+ + اجراها + جدیدترین اجراها در همهٔ کارها. + {rows.length === 0 ? ( +

هنوز اجرایی ثبت نشده است.

+ ) : ( +
    + {rows.map(({ run, taskId, taskTitle }) => ( +
  • + +
  • + ))} +
+ )} +
+ + {detailLoading && } + + {selected && !detailLoading && ( + +
+
+ خط زمانی اجرا + + {selected.id} + +
+ {selected.status} +
+ +
+ {selected.steps.length === 0 ? ( +

گامی ثبت نشده است.

+ ) : ( + ({ + id: step.id, + title: step.title || step.kind, + meta: new Date(step.created_at).toLocaleString("fa-IR"), + tone: + step.kind === "error" + ? ("danger" as const) + : step.kind === "artifact" + ? ("success" as const) + : ("info" as const), + }))} + /> + )} +
+ + {selected.error && ( +
+ +
+ )} + + {selected.tool_calls.length > 0 && ( +
+

ابزارها

+
    + {selected.tool_calls.map((call) => ( +
  • + {call.tool_key} +
    + {call.requires_approval && نیازمند تأیید} + + {call.status} + +
    +
  • + ))} +
+
+ )} + + {selected.artifacts.length > 0 && ( +
+

خروجی‌ها

+
    + {selected.artifacts.map((artifact) => ( +
  • + {artifact.name} ·{" "} + {artifact.kind} ·{" "} + {artifact.size_bytes} بایت +
  • + ))} +
+
+ )} +
+ )} + + {!selected && !detailLoading && rows.length > 0 && ( + + )} +
+ + + رویدادهای audit + هر اقدام حساس با کنشگر و هدف ثبت می‌شود. +
+ {audit.length === 0 ? ( +

رویدادی ثبت نشده است.

+ ) : ( + ({ + id: event.id, + title: auditLabel(event.action), + meta: `${new Date(event.created_at).toLocaleString("fa-IR")} · ${ + event.target_type + }`, + tone: event.action.includes("remove") || event.action.includes("cancel") + ? ("danger" as const) + : ("info" as const), + }))} + /> + )} +
+
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(app)/workforce/[id]/page.tsx b/apps/web/src/app/(app)/workforce/[id]/page.tsx new file mode 100644 index 0000000..d282fb5 --- /dev/null +++ b/apps/web/src/app/(app)/workforce/[id]/page.tsx @@ -0,0 +1,386 @@ +"use client"; + +import Link from "next/link"; +import * as React from "react"; +import type { + EmployeeDetail, + EmployeeStatus, + PromptVersion, + RoleKey, + RoutingChoice, + ToolSpec, +} from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, + Stat, + Timeline, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions"; +import { useSession } from "@/lib/session"; + +const ROLE_LABELS: Record = { + manager: "مدیر", + planner: "تحلیل‌گر", + developer: "برنامه‌نویس", + reviewer: "بازبین کد", + tester: "تستر", + researcher: "محقق", + content_creator: "تولیدکننده محتوا", + support: "پشتیبان", +}; + +const STATUS_LABELS: Record = { + active: "فعال", + paused: "متوقف", + archived: "بایگانی", +}; + +export default function EmployeeDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id: employeeId } = React.use(params); + const { activeOrganization } = useSession(); + const orgId = activeOrganization?.id ?? null; + + const [employee, setEmployee] = React.useState(null); + const [choices, setChoices] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [name, setName] = React.useState(""); + const [status, setStatus] = React.useState("active"); + const [routingPolicyId, setRoutingPolicyId] = React.useState(""); + const [saving, setSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const [saved, setSaved] = React.useState(false); + + const [promptDraft, setPromptDraft] = React.useState(""); + const [addingPrompt, setAddingPrompt] = React.useState(false); + const [promptError, setPromptError] = React.useState(null); + + const [tools, setTools] = React.useState([]); + const [toolBusy, setToolBusy] = React.useState(null); + const [toolError, setToolError] = React.useState(null); + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const [nextEmployee, nextChoices, nextTools] = await Promise.all([ + api.getEmployee(orgId, employeeId), + api.listRoutingChoices(orgId), + api.listTools(orgId), + ]); + setEmployee(nextEmployee); + setChoices(nextChoices); + setTools(nextTools); + setName(nextEmployee.name); + setStatus(nextEmployee.status); + setRoutingPolicyId(nextEmployee.routing_policy_id ?? ""); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت کارمند ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId, employeeId]); + + const onToggleTool = async (toolKey: string, allowed: boolean) => { + if (!orgId) return; + setToolError(null); + setToolBusy(toolKey); + try { + const updated = await api.setToolPermission(orgId, employeeId, toolKey, allowed); + setEmployee((current) => { + if (!current) return current; + const others = current.tool_permissions.filter((p) => p.tool_key !== toolKey); + return { ...current, tool_permissions: [...others, updated] }; + }); + } catch (err) { + setToolError(err instanceof ApiError ? err.message : "تغییر دسترسی ناموفق بود."); + } finally { + setToolBusy(null); + } + }; + + React.useEffect(() => { + void load(); + }, [load]); + + const onSave = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId) return; + setSaveError(null); + setSaved(false); + setSaving(true); + try { + await api.updateEmployee(orgId, employeeId, { + name: name.trim(), + status, + routing_policy_id: routingPolicyId || null, + }); + setSaved(true); + window.setTimeout(() => setSaved(false), 1800); + await load(); + } catch (err) { + setSaveError(err instanceof ApiError ? err.message : "ذخیرهٔ تغییرات ناموفق بود."); + } finally { + setSaving(false); + } + }; + + const onAddPrompt = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId) return; + setPromptError(null); + setAddingPrompt(true); + try { + const version: PromptVersion = await api.addPromptVersion( + orgId, + employeeId, + promptDraft.trim(), + ); + setEmployee((current) => + current + ? { ...current, prompt_versions: [version, ...current.prompt_versions] } + : current, + ); + setPromptDraft(""); + } catch (err) { + setPromptError(err instanceof ApiError ? err.message : "ثبت نسخهٔ پرامپت ناموفق بود."); + } finally { + setAddingPrompt(false); + } + }; + + if (!activeOrganization) { + return ( + + ); + } + + if (loading) return ; + if (error) return ; + if (!employee) { + return ( + + ); + } + + const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES); + const allowedTools = employee.tool_permissions.filter((p) => p.allowed); + + return ( +
+ + +
+

{employee.name}

+ {ROLE_LABELS[employee.role_key]} + + {STATUS_LABELS[employee.status]} + +
+ +
+ + + +
+ + + تنظیمات کارمند + + «سیاست کاری» تعیین می‌کند کارمند با چه سبکی کار کند. انتخاب مدل و ارائه‌دهنده در اختیار + سامانه است و نمایش داده نمی‌شود. + + +
+ setName(e.target.value)} + disabled={!canManage} + /> + setRoutingPolicyId(e.target.value)} + disabled={!canManage} + options={[ + { value: "", label: "پیش‌فرض سازمان" }, + ...choices.map((c) => ({ value: c.id, label: c.name })), + ]} + /> +
+ {canManage && ( +
+ {saved && ذخیره شد.} + +
+ )} + + {saveError && ( +
+ +
+ )} +
+ + + تاریخچهٔ پرامپت + + هر ویرایش یک نسخهٔ جدید می‌سازد. نسخه‌ها حذف نمی‌شوند تا بتوان رفتار کارمند را ردیابی کرد. + + +
+ {employee.prompt_versions.length === 0 ? ( +

هنوز پرامپتی ثبت نشده است.

+ ) : ( + ({ + id: version.id, + title: `نسخهٔ ${version.version}`, + meta: version.content.slice(0, 140), + tone: "info" as const, + }))} + /> + )} +
+ + {canManage && ( +
+ setPromptDraft(e.target.value)} + placeholder="دستورهای این کارمند را بنویسید…" + /> +
+ +
+ + )} + {promptError && ( +
+ +
+ )} +
+ + + دسترسی ابزارها + + به‌صورت پیش‌فرض هیچ ابزاری مجاز نیست؛ هر دسترسی باید صریحاً داده شود. ابزارهای + بازگشت‌ناپذیر همیشه به تأیید انسانی نیاز دارند. + +
+ {employee.tool_permissions.length === 0 ? ( +

هیچ ابزاری برای این کارمند تعریف نشده است.

+ ) : ( +
+ {employee.tool_permissions.map((permission) => ( + + {permission.tool_key} + + ))} +
+ )} +
+ + {canManage && ( +
+

ویرایش دسترسی‌ها

+
+ {tools.length === 0 ? ( + + ) : ( + tools.map((tool) => { + const granted = + employee.tool_permissions.find((p) => p.tool_key === tool.key)?.allowed ?? + false; + return ( +
+
+
+ {tool.label} + {tool.key} + {tool.requires_approval && تأیید لازم} + {tool.destructive && بازگشت‌ناپذیر} +
+ {tool.description} +
+ +
+ ); + }) + )} +
+ {toolError && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/workforce/page.tsx b/apps/web/src/app/(app)/workforce/page.tsx new file mode 100644 index 0000000..60f91d2 --- /dev/null +++ b/apps/web/src/app/(app)/workforce/page.tsx @@ -0,0 +1,269 @@ +"use client"; + +import Link from "next/link"; +import * as React from "react"; +import type { + AgentTemplate, + Employee, + EmployeeDetail, + RoleKey, +} from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions"; +import { useSession } from "@/lib/session"; + +const ROLE_LABELS: Record = { + manager: "مدیر", + planner: "تحلیل‌گر", + developer: "برنامه‌نویس", + reviewer: "بازبین کد", + tester: "تستر", + researcher: "محقق", + content_creator: "تولیدکننده محتوا", + support: "پشتیبان", +}; + +const STATUS_TONES = { + active: "success", + paused: "warning", + archived: "neutral", +} as const; + +/** + * Above this size we stop enriching cards with per-employee detail, because the + * enrichment costs one request per employee. The page stays usable either way. + */ +const ENRICH_LIMIT = 50; + +export default function WorkforcePage() { + const { activeOrganization } = useSession(); + const [employees, setEmployees] = React.useState([]); + const [details, setDetails] = React.useState>({}); + const [templates, setTemplates] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [showForm, setShowForm] = React.useState(false); + const [name, setName] = React.useState(""); + const [templateId, setTemplateId] = React.useState(""); + const [roleKey, setRoleKey] = React.useState("developer"); + const [creating, setCreating] = React.useState(false); + const [formError, setFormError] = React.useState(null); + + const orgId = activeOrganization?.id ?? null; + + const load = React.useCallback(async () => { + if (!orgId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const [nextEmployees, nextTemplates] = await Promise.all([ + api.listEmployees(orgId), + api.listAgentTemplates(orgId), + ]); + setEmployees(nextEmployees); + setTemplates(nextTemplates); + + if (nextEmployees.length > 0 && nextEmployees.length <= ENRICH_LIMIT) { + const enriched = await Promise.all( + nextEmployees.map(async (employee) => { + try { + return [employee.id, await api.getEmployee(orgId, employee.id)] as const; + } catch { + // A single failed enrichment must not blank the whole page. + return null; + } + }), + ); + setDetails( + Object.fromEntries( + enriched.filter((entry): entry is readonly [string, EmployeeDetail] => entry !== null), + ), + ); + } else { + setDetails({}); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت کارمندها ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId]); + + React.useEffect(() => { + void load(); + }, [load]); + + const onTemplateChange = (value: string) => { + setTemplateId(value); + const template = templates.find((t) => t.id === value); + if (template) { + setRoleKey(template.role_key); + if (!name.trim()) setName(template.name); + } + }; + + const onCreate = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId) return; + setFormError(null); + setCreating(true); + try { + await api.createEmployee(orgId, { + name: name.trim(), + role_key: roleKey, + template_id: templateId || null, + }); + setShowForm(false); + setName(""); + setTemplateId(""); + await load(); + } catch (err) { + setFormError(err instanceof ApiError ? err.message : "ساخت کارمند ناموفق بود."); + } finally { + setCreating(false); + } + }; + + if (!activeOrganization) { + return ( + + ); + } + + const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES); + + return ( +
+
+
+

کارمندهای هوش مصنوعی

+

+ هر کارمند یک نقش، یک پرامپت نسخه‌بندی‌شده و یک سیاست مسیریابی دارد. جزئیات مدل هرگز + نمایش داده نمی‌شود. +

+
+ {canManage && ( + + )} +
+ + {showForm && ( + + ساخت کارمند + + با انتخاب یک قالب، پرامپت نسخهٔ ۱ و دسترسی ابزارها به‌صورت خودکار ساخته می‌شود. + +
+
+ setRoleKey(e.target.value as RoleKey)} + options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ + value, + label, + }))} + /> +
+
+ +
+
+ {formError && ( +
+ +
+ )} +
+ )} + + {loading ? ( + + ) : error ? ( + + ) : employees.length === 0 ? ( + + ) : ( +
+ {employees.map((employee) => { + const detail = details[employee.id]; + return ( + +
+
+

{employee.name}

+

{ROLE_LABELS[employee.role_key]}

+
+ + {employee.status} + +
+ +
+ + کارهای باز: {detail ? detail.open_task_count : "—"} + + + نسخهٔ پرامپت: {detail ? detail.prompt_versions.length : "—"} + + + ابزارها: {detail ? detail.tool_permissions.filter((p) => p.allowed).length : "—"} + +
+ + + + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..465276b --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,36 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* + * Dark-first, RTL. The font stack prefers a Persian face when the user has one + * installed and falls back to the system UI font — no network fetch at build + * time, so `pnpm build` works offline. + */ +:root { + --font-sans: Vazirmatn, "IRANSans", "Segoe UI", Tahoma, system-ui, -apple-system, + "Noto Sans Arabic", sans-serif; + color-scheme: dark; +} + +html, +body { + height: 100%; +} + +body { + font-family: var(--font-sans); + background-color: #0b0d12; + color: #e2e8f0; + -webkit-font-smoothing: antialiased; +} + +/* Numerals and latin fragments inside Persian text read better LTR. */ +.ltr-inline { + direction: ltr; + unicode-bidi: embed; +} + +::selection { + background-color: #2b3b63; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..f23b7dc --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { SessionProvider } from "@/lib/session"; + +export const metadata: Metadata = { + title: "AGY Web — پلتفرم نیروی کار هوش مصنوعی", + description: "سازمان بسازید، کارمند هوش مصنوعی استخدام کنید و کارها را بسپارید.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..2090647 --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui"; +import { ApiError } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +export default function LoginPage() { + const router = useRouter(); + const { login, user, ready } = useSession(); + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [error, setError] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + if (ready && user) router.replace("/dashboard"); + }, [ready, user, router]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(email, password); + router.replace("/dashboard"); + } catch (err) { + setError(err instanceof ApiError ? err.message : "ورود ناموفق بود."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + ورود به AGY Web + با ایمیل و رمز عبور خود وارد شوید. + +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + {error && } + + + + +

+ حساب ندارید؟{" "} + + ثبت‌نام کنید + +

+
+
+ ); +} diff --git a/apps/web/src/app/onboarding/page.tsx b/apps/web/src/app/onboarding/page.tsx new file mode 100644 index 0000000..028530c --- /dev/null +++ b/apps/web/src/app/onboarding/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +/** First-run flow: create the user's first organization. */ +export default function OnboardingPage() { + const router = useRouter(); + const { ready, user, organizations, refresh } = useSession(); + const [name, setName] = React.useState(""); + const [error, setError] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + if (ready && !user) router.replace("/login"); + }, [ready, user, router]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await api.createOrganization(name.trim()); + await refresh(); + router.replace("/dashboard"); + } catch (err) { + setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود."); + } finally { + setSubmitting(false); + } + }; + + if (!ready) { + return ( +
+ +
+ ); + } + + return ( +
+ + ساخت اولین سازمان + + سازمان ظرف اصلی محصول است: اعضا، پروژه‌ها، کارمندهای هوش مصنوعی و مصرف + همه به سازمان تعلق دارند. + + + {organizations.length > 0 && ( +
+

سازمان‌های فعلی شما:

+ {organizations.map((org) => ( + + ))} +
+
+ )} + +
+ setName(e.target.value)} + placeholder="مثلاً شرکت نمونه" + /> + {error && } + + + +
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..6c43e6b --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { LoadingState } from "@agy/ui"; +import { useSession } from "@/lib/session"; + +/** Entry point: route the visitor to the dashboard or the login screen. */ +export default function RootPage() { + const { ready, user } = useSession(); + const router = useRouter(); + + React.useEffect(() => { + if (!ready) return; + router.replace(user ? "/dashboard" : "/login"); + }, [ready, user, router]); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx new file mode 100644 index 0000000..7834da5 --- /dev/null +++ b/apps/web/src/app/signup/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui"; +import { ApiError } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +export default function SignupPage() { + const router = useRouter(); + const { signup } = useSession(); + const [name, setName] = React.useState(""); + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [error, setError] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await signup(email, password, name); + router.replace("/onboarding"); + } catch (err) { + setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + ساخت حساب + پس از ثبت‌نام، اولین سازمان خود را می‌سازید. + +
+ setName(e.target.value)} + placeholder="نام شما" + /> + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + {error && } + + + + +

+ قبلاً ثبت‌نام کرده‌اید؟{" "} + + ورود + +

+
+
+ ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx new file mode 100644 index 0000000..9b1069d --- /dev/null +++ b/apps/web/src/components/AppShell.tsx @@ -0,0 +1,302 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import * as React from "react"; +import { Badge, Button, cx } from "@agy/ui"; +import { api } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +const NAV_ITEMS: { href: string; label: string; hint: string }[] = [ + { href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" }, + { href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" }, + { href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" }, + { href: "/projects", label: "پروژه‌ها", hint: "Git و محیط اجرا" }, + { href: "/tasks", label: "وظایف", hint: "وضعیت کارها" }, + { href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" }, + { href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" }, + { href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" }, + { href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" }, +]; + +/** + * Command/search over the navigation. + * + * Deliberately client-side: the app has a fixed set of destinations, so + * filtering them needs no round trip and works while offline. + */ +function CommandSearch() { + const router = useRouter(); + const [query, setQuery] = React.useState(""); + const [open, setOpen] = React.useState(false); + + const matches = React.useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return []; + return NAV_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(needle) || + item.hint.toLowerCase().includes(needle) || + item.href.includes(needle), + ); + }, [query]); + + const go = (href: string) => { + setQuery(""); + setOpen(false); + router.push(href); + }; + + return ( +
+ { + setQuery(event.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onKeyDown={(event) => { + if (event.key === "Enter" && matches.length > 0) go(matches[0].href); + if (event.key === "Escape") setQuery(""); + }} + className="h-9 w-56 rounded-lg border border-surface-border bg-surface-overlay px-3 text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" + /> + {open && query.trim() !== "" && ( +
+ {matches.length === 0 ? ( +

نتیجه‌ای پیدا نشد.

+ ) : ( + matches.map((item) => ( + + )) + )} +
+ )} +
+ ); +} + +/** Credit badge — reads the organization's real balance, never a placeholder. */ +function CreditBadge({ organizationId }: { organizationId: string | undefined }) { + const [balance, setBalance] = React.useState(null); + const [low, setLow] = React.useState(false); + + React.useEffect(() => { + if (!organizationId) { + setBalance(null); + return; + } + let cancelled = false; + api + .billingSummary(organizationId) + .then((summary) => { + if (cancelled) return; + setBalance(summary.balance); + setLow(summary.low_balance); + }) + .catch(() => { + // A failed balance must not break the shell; show it as unknown. + if (!cancelled) setBalance(null); + }); + return () => { + cancelled = true; + }; + }, [organizationId]); + + if (balance === null) return اعتبار: —; + return ( + + اعتبار: {balance.toFixed(2)} {low ? "(کم)" : ""} + + ); +} + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const { user, organizations, activeOrganization, setActiveOrganization, logout } = useSession(); + const [menuOpen, setMenuOpen] = React.useState(false); + const [userMenuOpen, setUserMenuOpen] = React.useState(false); + + const handleLogout = async () => { + await logout(); + router.replace("/login"); + }; + + return ( +
+ + +
+
+
+
+ + {menuOpen && ( +
+ {organizations.length === 0 && ( +

هنوز سازمانی نساخته‌اید.

+ )} + {organizations.map((org) => ( + + ))} +
+ setMenuOpen(false)} + > + ساخت سازمان جدید + +
+ )} +
+ + + + +
+ +
+ + {userMenuOpen && ( +
+
+

+ {user?.name || "کاربر"} +

+

{user?.email}

+
+
+ setUserMenuOpen(false)} + className="block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised" + > + تنظیمات سازمان + + {user?.is_platform_owner && ( + setUserMenuOpen(false)} + className="block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised" + > + کنسول مالک سامانه + + )} + +
+ )} +
+
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/PlaceholderPage.tsx b/apps/web/src/components/PlaceholderPage.tsx new file mode 100644 index 0000000..27009a3 --- /dev/null +++ b/apps/web/src/components/PlaceholderPage.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Card, CardHint, CardTitle } from "@agy/ui"; + +/** + * Placeholder for a page whose backend phase has not landed yet. + * + * It is deliberately explicit about *what* will live here rather than showing a + * vague "coming soon" — a reader can tell whether the page is missing or the + * feature is. + */ +export function PlaceholderPage({ + title, + description, + planned, +}: { + title: string; + description: string; + planned: string[]; +}) { + return ( +
+
+

{title}

+

{description}

+
+ + + در این صفحه چه چیزی ساخته می‌شود + + این بخش در فاز بعدی پیاده‌سازی می‌شود. فهرست زیر قرارداد نهایی این صفحه است. + +
    + {planned.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/sandbox-panel.tsx b/apps/web/src/components/sandbox-panel.tsx new file mode 100644 index 0000000..1b6b599 --- /dev/null +++ b/apps/web/src/components/sandbox-panel.tsx @@ -0,0 +1,594 @@ +"use client"; + +/** + * Sandbox panel for a project. + * + * Two things this UI deliberately does NOT do: + * + * 1. It never sends a timeout or an output cap. Those are resolved server-side + * from policy (project -> organization -> platform) and clamped, so a + * tampered client cannot widen them. + * 2. It shows no shell/exec affordance for the platform host. The only execution + * surface is a workspace session, which is isolated and metered. + */ + +import * as React from "react"; +import type { + SandboxCommandRun, + SandboxResourceLimit, + SandboxSession, + SandboxTemplate, + SandboxWorkspace, +} from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, + Stat, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; + +const STATUS_TONES = { + ready: "success", + running: "success", + pending: "warning", + starting: "warning", + paused: "neutral", + stopped: "neutral", + destroyed: "danger", + failed: "danger", +} as const; + +function toneFor(status: string) { + return STATUS_TONES[status as keyof typeof STATUS_TONES] ?? "neutral"; +} + +function humanBytes(bytes: number): string { + if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} مگابایت`; + if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} کیلوبایت`; + return `${bytes} بایت`; +} + +export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: string }) { + const [templates, setTemplates] = React.useState([]); + const [workspaces, setWorkspaces] = React.useState([]); + const [limits, setLimits] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [templateId, setTemplateId] = React.useState(""); + const [creating, setCreating] = React.useState(false); + const [pendingDestroy, setPendingDestroy] = React.useState(null); + const [destroying, setDestroying] = React.useState(false); + + const [activeWorkspace, setActiveWorkspace] = React.useState(null); + const [session, setSession] = React.useState(null); + const [starting, setStarting] = React.useState(false); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextTemplates, nextWorkspaces, nextLimits] = await Promise.all([ + api.sandboxTemplates(orgId), + api.sandboxWorkspaces(orgId, projectId), + api.sandboxLimits(orgId, projectId), + ]); + setTemplates(nextTemplates); + setWorkspaces(nextWorkspaces); + setLimits(nextLimits); + setTemplateId((current) => current || nextTemplates[0]?.id || ""); + } catch (err) { + setError(err instanceof ApiError ? err.message : "دریافت اطلاعات sandbox ناموفق بود."); + } finally { + setLoading(false); + } + }, [orgId, projectId]); + + React.useEffect(() => { + void load(); + }, [load]); + + const onCreateWorkspace = async (event: React.FormEvent) => { + event.preventDefault(); + if (!templateId) return; + setCreating(true); + setError(null); + try { + const created = await api.sandboxCreateWorkspace(orgId, { + project_id: projectId, + template_id: templateId, + }); + setWorkspaces((current) => [created, ...current]); + setActiveWorkspace(created); + setSession(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "ساخت محیط ایزوله ناموفق بود."); + } finally { + setCreating(false); + } + }; + + const onConfirmDestroy = async () => { + if (!pendingDestroy) return; + setDestroying(true); + try { + await api.sandboxDestroyWorkspace(orgId, pendingDestroy.id); + setWorkspaces((current) => current.filter((w) => w.id !== pendingDestroy.id)); + if (activeWorkspace?.id === pendingDestroy.id) { + setActiveWorkspace(null); + setSession(null); + } + setPendingDestroy(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "حذف محیط ناموفق بود."); + setPendingDestroy(null); + } finally { + setDestroying(false); + } + }; + + const onStartSession = async (workspace: SandboxWorkspace) => { + setStarting(true); + setError(null); + try { + setSession(await api.sandboxStartSession(orgId, workspace.id)); + setActiveWorkspace(workspace); + } catch (err) { + setError(err instanceof ApiError ? err.message : "شروع نشست ناموفق بود."); + } finally { + setStarting(false); + } + }; + + if (loading) return ; + + return ( +
+ {error && } + + {limits && ( + + محدودیت‌های مؤثر + + این مقادیر از سیاست سازمان و سکو محاسبه و در سرور محدود می‌شوند. کلاینت نمی‌تواند + آن‌ها را تغییر دهد. + +
+ + + + + +
+

+ سیاست شبکه: {limits.network_policy} + {limits.network_policy === "deny_all" && " — دسترسی خروجی بسته است."} +

+
+ )} + + + ساخت محیط ایزوله + + کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر + دستور در دفتر مصرف ثبت می‌شود. + + {templates.length === 0 ? ( +

قالبی در دسترس نیست.

+ ) : ( +
+
+