نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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
|
||||||
+51
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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: <http://localhost:3000>
|
||||||
|
- API: <http://localhost:8000>
|
||||||
|
- API docs (Swagger): <http://localhost:8000/docs>
|
||||||
|
- Health: <http://localhost:8000/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/<context>` |
|
||||||
|
| adapter یا اتصال به سرویس بیرونی | `apps/api/app/infrastructure` |
|
||||||
|
| worker پسزمینه بنویسم | `apps/api/app/workers` (پوستهٔ اجرا در `services/<name>`) |
|
||||||
|
| خطا ثبت/پاکسازی کنم | `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/<name>`
|
||||||
|
پوستهٔ اجرای آنهاست:
|
||||||
|
|
||||||
|
| 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) — زیرساخت فعلی (مرجع)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: c7ecdf7882fe
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-10-12 21:29:22.888817
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'c7ecdf7882fe'
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('agent_templates',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('role_key', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('default_prompt', sa.Text(), nullable=False),
|
||||||
|
sa.Column('default_tools_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('default_routing_policy_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('is_system', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('ai_providers',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('base_url', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('audit_events',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('actor_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('action', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('target_id', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('metadata_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('ip_address', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('user_agent', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_audit_action', 'audit_events', ['action'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_events_actor_user_id'), 'audit_events', ['actor_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_events_organization_id'), 'audit_events', ['organization_id'], unique=False)
|
||||||
|
op.create_index('ix_audit_org_created', 'audit_events', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_table('error_events',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('run_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('kind', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('message', sa.Text(), nullable=False),
|
||||||
|
sa.Column('stack', sa.Text(), nullable=False),
|
||||||
|
sa.Column('metadata_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_error_events_organization_id'), 'error_events', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_error_events_run_id'), 'error_events', ['run_id'], unique=False)
|
||||||
|
op.create_table('plans',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('monthly_price', sa.Float(), nullable=False),
|
||||||
|
sa.Column('included_credit', sa.Float(), nullable=False),
|
||||||
|
sa.Column('features_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('quality_metrics',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('value', sa.Float(), nullable=False),
|
||||||
|
sa.Column('dimensions_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_metric_org_name', 'quality_metrics', ['organization_id', 'name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_quality_metrics_organization_id'), 'quality_metrics', ['organization_id'], unique=False)
|
||||||
|
op.create_table('rate_cards',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('unit', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('customer_price', sa.Float(), nullable=False),
|
||||||
|
sa.Column('internal_cost_estimate', sa.Float(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('unit')
|
||||||
|
)
|
||||||
|
op.create_table('routing_policies',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('mode', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('fallback_policy_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('budget_limits_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('sandbox_resource_limits',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('scope', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('scope_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('cpu', sa.Float(), nullable=False),
|
||||||
|
sa.Column('memory_mb', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('disk_mb', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timeout_seconds', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('max_output_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('network_policy', sa.String(length=16), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('scope', 'scope_id', name='uq_limit_scope')
|
||||||
|
)
|
||||||
|
op.create_table('sandbox_templates',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('image', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('default_cpu', sa.Float(), nullable=False),
|
||||||
|
sa.Column('default_memory_mb', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('default_disk_mb', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_system', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('traces',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('run_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_traces_organization_id'), 'traces', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_traces_run_id'), 'traces', ['run_id'], unique=False)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('avatar_url', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('is_platform_owner', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
|
op.create_table('ai_model_credentials',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('provider_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('encrypted_api_key', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['provider_id'], ['ai_providers.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_ai_model_credentials_provider_id'), 'ai_model_credentials', ['provider_id'], unique=False)
|
||||||
|
op.create_table('ai_models',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('provider_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('internal_name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('display_name_for_admin', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('capabilities', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('input_price_per_1m', sa.Float(), nullable=False),
|
||||||
|
sa.Column('output_price_per_1m', sa.Float(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['provider_id'], ['ai_providers.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('provider_id', 'internal_name', name='uq_model_provider_name')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_ai_models_provider_id'), 'ai_models', ['provider_id'], unique=False)
|
||||||
|
op.create_table('organizations',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_organizations_owner_user_id'), 'organizations', ['owner_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True)
|
||||||
|
op.create_table('spans',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('trace_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('parent_span_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('attributes_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['trace_id'], ['traces.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_spans_trace_id'), 'spans', ['trace_id'], unique=False)
|
||||||
|
op.create_table('credit_wallets',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('balance', sa.Float(), nullable=False),
|
||||||
|
sa.Column('currency', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('low_balance_threshold', sa.Float(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_credit_wallets_organization_id'), 'credit_wallets', ['organization_id'], unique=True)
|
||||||
|
op.create_table('invites',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('invited_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('accepted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['invited_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token_hash')
|
||||||
|
)
|
||||||
|
op.create_index('ix_invite_org_email', 'invites', ['organization_id', 'email'], unique=False)
|
||||||
|
op.create_index(op.f('ix_invites_invited_by_user_id'), 'invites', ['invited_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_invites_organization_id'), 'invites', ['organization_id'], unique=False)
|
||||||
|
op.create_table('invoices',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('amount', sa.Float(), nullable=False),
|
||||||
|
sa.Column('currency', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('external_invoice_id', sa.String(length=160), nullable=True),
|
||||||
|
sa.Column('period_start', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('period_end', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_invoices_organization_id'), 'invoices', ['organization_id'], unique=False)
|
||||||
|
op.create_table('knowledge_sources',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('uri', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_knowledge_sources_organization_id'), 'knowledge_sources', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_knowledge_sources_project_id'), 'knowledge_sources', ['project_id'], unique=False)
|
||||||
|
op.create_table('memberships',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('organization_id', 'user_id', name='uq_membership_org_user')
|
||||||
|
)
|
||||||
|
op.create_index('ix_membership_org_role', 'memberships', ['organization_id', 'role'], unique=False)
|
||||||
|
op.create_index(op.f('ix_memberships_organization_id'), 'memberships', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_memberships_user_id'), 'memberships', ['user_id'], unique=False)
|
||||||
|
op.create_table('payments',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('amount', sa.Float(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('external_payment_id', sa.String(length=160), nullable=True),
|
||||||
|
sa.Column('invoice_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_payments_organization_id'), 'payments', ['organization_id'], unique=False)
|
||||||
|
op.create_table('projects',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('default_branch', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('organization_id', 'slug', name='uq_project_org_slug')
|
||||||
|
)
|
||||||
|
op.create_index('ix_project_org_created', 'projects', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_projects_created_by_user_id'), 'projects', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_projects_organization_id'), 'projects', ['organization_id'], unique=False)
|
||||||
|
op.create_table('retrieval_policies',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('scope_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_retrieval_policies_organization_id'), 'retrieval_policies', ['organization_id'], unique=False)
|
||||||
|
op.create_table('subscriptions',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('plan_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('external_subscription_id', sa.String(length=160), nullable=True),
|
||||||
|
sa.Column('current_period_end', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_subscriptions_organization_id'), 'subscriptions', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_subscriptions_plan_id'), 'subscriptions', ['plan_id'], unique=False)
|
||||||
|
op.create_table('usage_events',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('employee_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('run_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('unit', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('quantity', sa.Float(), nullable=False),
|
||||||
|
sa.Column('unit_price', sa.Float(), nullable=False),
|
||||||
|
sa.Column('internal_cost_estimate', sa.Float(), nullable=False),
|
||||||
|
sa.Column('total_price', sa.Float(), nullable=False),
|
||||||
|
sa.Column('metadata_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_usage_events_employee_id'), 'usage_events', ['employee_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_organization_id'), 'usage_events', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_run_id'), 'usage_events', ['run_id'], unique=False)
|
||||||
|
op.create_index('ix_usage_org_created', 'usage_events', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index('ix_usage_org_unit', 'usage_events', ['organization_id', 'unit'], unique=False)
|
||||||
|
op.create_table('documents',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('source_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['source_id'], ['knowledge_sources.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_documents_source_id'), 'documents', ['source_id'], unique=False)
|
||||||
|
op.create_table('embedding_jobs',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('source_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['source_id'], ['knowledge_sources.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_embedding_jobs_source_id'), 'embedding_jobs', ['source_id'], unique=False)
|
||||||
|
op.create_table('employees',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('template_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('role_key', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('routing_policy_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('budget_limit_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_employee_org_created', 'employees', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_employees_organization_id'), 'employees', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_employees_project_id'), 'employees', ['project_id'], unique=False)
|
||||||
|
op.create_table('preview_domains',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('host', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('port', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('protected', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('host', name='uq_preview_domain_host')
|
||||||
|
)
|
||||||
|
op.create_index('ix_preview_domain_org_created', 'preview_domains', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_preview_domains_created_by_user_id'), 'preview_domains', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_preview_domains_organization_id'), 'preview_domains', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_preview_domains_project_id'), 'preview_domains', ['project_id'], unique=False)
|
||||||
|
op.create_table('preview_ports',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('service_name', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('port', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('port', name='uq_preview_port_port'),
|
||||||
|
sa.UniqueConstraint('project_id', 'service_name', name='uq_preview_port_project_service')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_preview_ports_organization_id'), 'preview_ports', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_preview_ports_project_id'), 'preview_ports', ['project_id'], unique=False)
|
||||||
|
op.create_table('project_git_credentials',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('encrypted_password', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_project_git_credentials_project_id'), 'project_git_credentials', ['project_id'], unique=True)
|
||||||
|
op.create_table('project_repositories',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('owner', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('repo_name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('clone_url', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('web_url', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('default_branch', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_project_repositories_project_id'), 'project_repositories', ['project_id'], unique=True)
|
||||||
|
op.create_table('threads',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_thread_org_created', 'threads', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_threads_created_by_user_id'), 'threads', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_threads_organization_id'), 'threads', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_threads_project_id'), 'threads', ['project_id'], unique=False)
|
||||||
|
op.create_table('workspaces',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('template_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('external_workspace_id', sa.String(length=160), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['template_id'], ['sandbox_templates.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_workspace_project_created', 'workspaces', ['project_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_workspaces_organization_id'), 'workspaces', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_workspaces_project_id'), 'workspaces', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_workspaces_template_id'), 'workspaces', ['template_id'], unique=False)
|
||||||
|
op.create_table('document_chunks',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('document_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('index', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('embedding_json', sa.JSON(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_chunk_document_index', 'document_chunks', ['document_id', 'index'], unique=False)
|
||||||
|
op.create_index(op.f('ix_document_chunks_document_id'), 'document_chunks', ['document_id'], unique=False)
|
||||||
|
op.create_table('employee_tool_permissions',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('employee_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('tool_key', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('allowed', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('policy_json', sa.JSON(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('employee_id', 'tool_key', name='uq_employee_tool')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_employee_tool_permissions_employee_id'), 'employee_tool_permissions', ['employee_id'], unique=False)
|
||||||
|
op.create_table('messages',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('thread_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('employee_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['thread_id'], ['threads.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_message_thread_created', 'messages', ['thread_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_messages_employee_id'), 'messages', ['employee_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_messages_thread_id'), 'messages', ['thread_id'], unique=False)
|
||||||
|
op.create_table('preview_urls',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('workspace_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('port', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('url', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_preview_urls_workspace_id'), 'preview_urls', ['workspace_id'], unique=False)
|
||||||
|
op.create_table('prompt_versions',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('employee_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('employee_id', 'version', name='uq_prompt_employee_version')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_prompt_versions_created_by_user_id'), 'prompt_versions', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_prompt_versions_employee_id'), 'prompt_versions', ['employee_id'], unique=False)
|
||||||
|
op.create_table('sandbox_sessions',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('workspace_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('external_session_id', sa.String(length=160), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('stopped_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sandbox_sessions_workspace_id'), 'sandbox_sessions', ['workspace_id'], unique=False)
|
||||||
|
op.create_table('snapshots',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('workspace_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('external_snapshot_id', sa.String(length=160), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_snapshots_workspace_id'), 'snapshots', ['workspace_id'], unique=False)
|
||||||
|
op.create_table('tasks',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('thread_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=24), nullable=False),
|
||||||
|
sa.Column('assigned_employee_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['thread_id'], ['threads.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_task_org_created', 'tasks', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index('ix_task_org_status', 'tasks', ['organization_id', 'status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_assigned_employee_id'), 'tasks', ['assigned_employee_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_created_by_user_id'), 'tasks', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_organization_id'), 'tasks', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_project_id'), 'tasks', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_thread_id'), 'tasks', ['thread_id'], unique=False)
|
||||||
|
op.create_table('command_runs',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('session_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('command', sa.Text(), nullable=False),
|
||||||
|
sa.Column('exit_code', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('stdout_ref', sa.Text(), nullable=False),
|
||||||
|
sa.Column('stderr_ref', sa.Text(), nullable=False),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('truncated', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['session_id'], ['sandbox_sessions.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_command_runs_session_id'), 'command_runs', ['session_id'], unique=False)
|
||||||
|
op.create_table('runs',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('task_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('employee_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=24), nullable=False),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_run_task_created', 'runs', ['task_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_runs_employee_id'), 'runs', ['employee_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_runs_task_id'), 'runs', ['task_id'], unique=False)
|
||||||
|
op.create_table('artifacts',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('organization_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('run_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('content_ref', sa.Text(), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_artifact_org_created', 'artifacts', ['organization_id', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_artifacts_organization_id'), 'artifacts', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_artifacts_project_id'), 'artifacts', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_artifacts_run_id'), 'artifacts', ['run_id'], unique=False)
|
||||||
|
op.create_table('run_steps',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('run_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('index', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('payload_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_run_step_run_index', 'run_steps', ['run_id', 'index'], unique=False)
|
||||||
|
op.create_index(op.f('ix_run_steps_run_id'), 'run_steps', ['run_id'], unique=False)
|
||||||
|
op.create_table('tool_calls',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('run_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('tool_key', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('arguments_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('result_json', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=24), nullable=False),
|
||||||
|
sa.Column('requires_approval', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('approved_by_user_id', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['approved_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_tool_call_run_created', 'tool_calls', ['run_id', 'created_at'], unique=False)
|
||||||
|
op.create_index('ix_tool_call_status', 'tool_calls', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tool_calls_approved_by_user_id'), 'tool_calls', ['approved_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tool_calls_run_id'), 'tool_calls', ['run_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_tool_calls_run_id'), table_name='tool_calls')
|
||||||
|
op.drop_index(op.f('ix_tool_calls_approved_by_user_id'), table_name='tool_calls')
|
||||||
|
op.drop_index('ix_tool_call_status', table_name='tool_calls')
|
||||||
|
op.drop_index('ix_tool_call_run_created', table_name='tool_calls')
|
||||||
|
op.drop_table('tool_calls')
|
||||||
|
op.drop_index(op.f('ix_run_steps_run_id'), table_name='run_steps')
|
||||||
|
op.drop_index('ix_run_step_run_index', table_name='run_steps')
|
||||||
|
op.drop_table('run_steps')
|
||||||
|
op.drop_index(op.f('ix_artifacts_run_id'), table_name='artifacts')
|
||||||
|
op.drop_index(op.f('ix_artifacts_project_id'), table_name='artifacts')
|
||||||
|
op.drop_index(op.f('ix_artifacts_organization_id'), table_name='artifacts')
|
||||||
|
op.drop_index('ix_artifact_org_created', table_name='artifacts')
|
||||||
|
op.drop_table('artifacts')
|
||||||
|
op.drop_index(op.f('ix_runs_task_id'), table_name='runs')
|
||||||
|
op.drop_index(op.f('ix_runs_employee_id'), table_name='runs')
|
||||||
|
op.drop_index('ix_run_task_created', table_name='runs')
|
||||||
|
op.drop_table('runs')
|
||||||
|
op.drop_index(op.f('ix_command_runs_session_id'), table_name='command_runs')
|
||||||
|
op.drop_table('command_runs')
|
||||||
|
op.drop_index(op.f('ix_tasks_thread_id'), table_name='tasks')
|
||||||
|
op.drop_index(op.f('ix_tasks_project_id'), table_name='tasks')
|
||||||
|
op.drop_index(op.f('ix_tasks_organization_id'), table_name='tasks')
|
||||||
|
op.drop_index(op.f('ix_tasks_created_by_user_id'), table_name='tasks')
|
||||||
|
op.drop_index(op.f('ix_tasks_assigned_employee_id'), table_name='tasks')
|
||||||
|
op.drop_index('ix_task_org_status', table_name='tasks')
|
||||||
|
op.drop_index('ix_task_org_created', table_name='tasks')
|
||||||
|
op.drop_table('tasks')
|
||||||
|
op.drop_index(op.f('ix_snapshots_workspace_id'), table_name='snapshots')
|
||||||
|
op.drop_table('snapshots')
|
||||||
|
op.drop_index(op.f('ix_sandbox_sessions_workspace_id'), table_name='sandbox_sessions')
|
||||||
|
op.drop_table('sandbox_sessions')
|
||||||
|
op.drop_index(op.f('ix_prompt_versions_employee_id'), table_name='prompt_versions')
|
||||||
|
op.drop_index(op.f('ix_prompt_versions_created_by_user_id'), table_name='prompt_versions')
|
||||||
|
op.drop_table('prompt_versions')
|
||||||
|
op.drop_index(op.f('ix_preview_urls_workspace_id'), table_name='preview_urls')
|
||||||
|
op.drop_table('preview_urls')
|
||||||
|
op.drop_index(op.f('ix_messages_thread_id'), table_name='messages')
|
||||||
|
op.drop_index(op.f('ix_messages_employee_id'), table_name='messages')
|
||||||
|
op.drop_index('ix_message_thread_created', table_name='messages')
|
||||||
|
op.drop_table('messages')
|
||||||
|
op.drop_index(op.f('ix_employee_tool_permissions_employee_id'), table_name='employee_tool_permissions')
|
||||||
|
op.drop_table('employee_tool_permissions')
|
||||||
|
op.drop_index(op.f('ix_document_chunks_document_id'), table_name='document_chunks')
|
||||||
|
op.drop_index('ix_chunk_document_index', table_name='document_chunks')
|
||||||
|
op.drop_table('document_chunks')
|
||||||
|
op.drop_index(op.f('ix_workspaces_template_id'), table_name='workspaces')
|
||||||
|
op.drop_index(op.f('ix_workspaces_project_id'), table_name='workspaces')
|
||||||
|
op.drop_index(op.f('ix_workspaces_organization_id'), table_name='workspaces')
|
||||||
|
op.drop_index('ix_workspace_project_created', table_name='workspaces')
|
||||||
|
op.drop_table('workspaces')
|
||||||
|
op.drop_index(op.f('ix_threads_project_id'), table_name='threads')
|
||||||
|
op.drop_index(op.f('ix_threads_organization_id'), table_name='threads')
|
||||||
|
op.drop_index(op.f('ix_threads_created_by_user_id'), table_name='threads')
|
||||||
|
op.drop_index('ix_thread_org_created', table_name='threads')
|
||||||
|
op.drop_table('threads')
|
||||||
|
op.drop_index(op.f('ix_project_repositories_project_id'), table_name='project_repositories')
|
||||||
|
op.drop_table('project_repositories')
|
||||||
|
op.drop_index(op.f('ix_project_git_credentials_project_id'), table_name='project_git_credentials')
|
||||||
|
op.drop_table('project_git_credentials')
|
||||||
|
op.drop_index(op.f('ix_preview_ports_project_id'), table_name='preview_ports')
|
||||||
|
op.drop_index(op.f('ix_preview_ports_organization_id'), table_name='preview_ports')
|
||||||
|
op.drop_table('preview_ports')
|
||||||
|
op.drop_index(op.f('ix_preview_domains_project_id'), table_name='preview_domains')
|
||||||
|
op.drop_index(op.f('ix_preview_domains_organization_id'), table_name='preview_domains')
|
||||||
|
op.drop_index(op.f('ix_preview_domains_created_by_user_id'), table_name='preview_domains')
|
||||||
|
op.drop_index('ix_preview_domain_org_created', table_name='preview_domains')
|
||||||
|
op.drop_table('preview_domains')
|
||||||
|
op.drop_index(op.f('ix_employees_project_id'), table_name='employees')
|
||||||
|
op.drop_index(op.f('ix_employees_organization_id'), table_name='employees')
|
||||||
|
op.drop_index('ix_employee_org_created', table_name='employees')
|
||||||
|
op.drop_table('employees')
|
||||||
|
op.drop_index(op.f('ix_embedding_jobs_source_id'), table_name='embedding_jobs')
|
||||||
|
op.drop_table('embedding_jobs')
|
||||||
|
op.drop_index(op.f('ix_documents_source_id'), table_name='documents')
|
||||||
|
op.drop_table('documents')
|
||||||
|
op.drop_index('ix_usage_org_unit', table_name='usage_events')
|
||||||
|
op.drop_index('ix_usage_org_created', table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_run_id'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_organization_id'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_employee_id'), table_name='usage_events')
|
||||||
|
op.drop_table('usage_events')
|
||||||
|
op.drop_index(op.f('ix_subscriptions_plan_id'), table_name='subscriptions')
|
||||||
|
op.drop_index(op.f('ix_subscriptions_organization_id'), table_name='subscriptions')
|
||||||
|
op.drop_table('subscriptions')
|
||||||
|
op.drop_index(op.f('ix_retrieval_policies_organization_id'), table_name='retrieval_policies')
|
||||||
|
op.drop_table('retrieval_policies')
|
||||||
|
op.drop_index(op.f('ix_projects_organization_id'), table_name='projects')
|
||||||
|
op.drop_index(op.f('ix_projects_created_by_user_id'), table_name='projects')
|
||||||
|
op.drop_index('ix_project_org_created', table_name='projects')
|
||||||
|
op.drop_table('projects')
|
||||||
|
op.drop_index(op.f('ix_payments_organization_id'), table_name='payments')
|
||||||
|
op.drop_table('payments')
|
||||||
|
op.drop_index(op.f('ix_memberships_user_id'), table_name='memberships')
|
||||||
|
op.drop_index(op.f('ix_memberships_organization_id'), table_name='memberships')
|
||||||
|
op.drop_index('ix_membership_org_role', table_name='memberships')
|
||||||
|
op.drop_table('memberships')
|
||||||
|
op.drop_index(op.f('ix_knowledge_sources_project_id'), table_name='knowledge_sources')
|
||||||
|
op.drop_index(op.f('ix_knowledge_sources_organization_id'), table_name='knowledge_sources')
|
||||||
|
op.drop_table('knowledge_sources')
|
||||||
|
op.drop_index(op.f('ix_invoices_organization_id'), table_name='invoices')
|
||||||
|
op.drop_table('invoices')
|
||||||
|
op.drop_index(op.f('ix_invites_organization_id'), table_name='invites')
|
||||||
|
op.drop_index(op.f('ix_invites_invited_by_user_id'), table_name='invites')
|
||||||
|
op.drop_index('ix_invite_org_email', table_name='invites')
|
||||||
|
op.drop_table('invites')
|
||||||
|
op.drop_index(op.f('ix_credit_wallets_organization_id'), table_name='credit_wallets')
|
||||||
|
op.drop_table('credit_wallets')
|
||||||
|
op.drop_index(op.f('ix_spans_trace_id'), table_name='spans')
|
||||||
|
op.drop_table('spans')
|
||||||
|
op.drop_index(op.f('ix_organizations_slug'), table_name='organizations')
|
||||||
|
op.drop_index(op.f('ix_organizations_owner_user_id'), table_name='organizations')
|
||||||
|
op.drop_table('organizations')
|
||||||
|
op.drop_index(op.f('ix_ai_models_provider_id'), table_name='ai_models')
|
||||||
|
op.drop_table('ai_models')
|
||||||
|
op.drop_index(op.f('ix_ai_model_credentials_provider_id'), table_name='ai_model_credentials')
|
||||||
|
op.drop_table('ai_model_credentials')
|
||||||
|
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_index(op.f('ix_traces_run_id'), table_name='traces')
|
||||||
|
op.drop_index(op.f('ix_traces_organization_id'), table_name='traces')
|
||||||
|
op.drop_table('traces')
|
||||||
|
op.drop_table('sandbox_templates')
|
||||||
|
op.drop_table('sandbox_resource_limits')
|
||||||
|
op.drop_table('routing_policies')
|
||||||
|
op.drop_table('rate_cards')
|
||||||
|
op.drop_index(op.f('ix_quality_metrics_organization_id'), table_name='quality_metrics')
|
||||||
|
op.drop_index('ix_metric_org_name', table_name='quality_metrics')
|
||||||
|
op.drop_table('quality_metrics')
|
||||||
|
op.drop_table('plans')
|
||||||
|
op.drop_index(op.f('ix_error_events_run_id'), table_name='error_events')
|
||||||
|
op.drop_index(op.f('ix_error_events_organization_id'), table_name='error_events')
|
||||||
|
op.drop_table('error_events')
|
||||||
|
op.drop_index('ix_audit_org_created', table_name='audit_events')
|
||||||
|
op.drop_index(op.f('ix_audit_events_organization_id'), table_name='audit_events')
|
||||||
|
op.drop_index(op.f('ix_audit_events_actor_user_id'), table_name='audit_events')
|
||||||
|
op.drop_index('ix_audit_action', table_name='audit_events')
|
||||||
|
op.drop_table('audit_events')
|
||||||
|
op.drop_table('ai_providers')
|
||||||
|
op.drop_table('agent_templates')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""extend error events for the error console
|
||||||
|
|
||||||
|
Turns ``error_events`` from a stub (``kind``/``message``/``stack``) into a record
|
||||||
|
detailed enough to diagnose a failure without reproducing it: where it happened
|
||||||
|
(``source``), what threw (``error_type``), who hit it (``user_id``, ``ip``), on
|
||||||
|
which request (``method``, ``path``, ``request_id``), and whether anyone has
|
||||||
|
dealt with it (``resolved_at``).
|
||||||
|
|
||||||
|
Every new NOT NULL column carries a ``server_default`` so existing rows backfill
|
||||||
|
without a table rewrite, and so the row stays insertable from plain SQL.
|
||||||
|
|
||||||
|
Revision ID: f5ba57af2906
|
||||||
|
Revises: c7ecdf7882fe
|
||||||
|
Create Date: 2026-10-12 23:17:57.352385
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'f5ba57af2906'
|
||||||
|
down_revision: str | None = 'c7ecdf7882fe'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
#: ``(name, type, server_default)``. ``None`` default means nullable, no default.
|
||||||
|
_NEW_COLUMNS: tuple[tuple[str, sa.types.TypeEngine, str | None], ...] = (
|
||||||
|
("source", sa.String(length=16), "'http'"),
|
||||||
|
("severity", sa.String(length=16), "'error'"),
|
||||||
|
("error_type", sa.String(length=200), "''"),
|
||||||
|
("fingerprint", sa.String(length=64), "''"),
|
||||||
|
("status_code", sa.Integer(), None),
|
||||||
|
("method", sa.String(length=10), "''"),
|
||||||
|
("path", sa.String(length=512), "''"),
|
||||||
|
("user_id", sa.String(length=32), None),
|
||||||
|
("request_id", sa.String(length=64), "''"),
|
||||||
|
("ip", sa.String(length=64), "''"),
|
||||||
|
("user_agent", sa.String(length=255), "''"),
|
||||||
|
("resolved_at", sa.DateTime(timezone=True), None),
|
||||||
|
("resolved_note", sa.Text(), "''"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name, column_type, default in _NEW_COLUMNS:
|
||||||
|
op.add_column(
|
||||||
|
"error_events",
|
||||||
|
sa.Column(
|
||||||
|
name,
|
||||||
|
column_type,
|
||||||
|
nullable=default is None,
|
||||||
|
server_default=None if default is None else sa.text(default),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_error_events_fingerprint"), "error_events", ["fingerprint"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_error_events_resolved_at"), "error_events", ["resolved_at"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_error_events_fingerprint_created",
|
||||||
|
"error_events",
|
||||||
|
["fingerprint", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_error_events_org_created",
|
||||||
|
"error_events",
|
||||||
|
["organization_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_error_events_org_created", table_name="error_events")
|
||||||
|
op.drop_index("ix_error_events_fingerprint_created", table_name="error_events")
|
||||||
|
op.drop_index(op.f("ix_error_events_resolved_at"), table_name="error_events")
|
||||||
|
op.drop_index(op.f("ix_error_events_fingerprint"), table_name="error_events")
|
||||||
|
for name, _column_type, _default in reversed(_NEW_COLUMNS):
|
||||||
|
op.drop_column("error_events", name)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Composition root for the application services.
|
||||||
|
|
||||||
|
This module builds every application service from a single ``AsyncSession``.
|
||||||
|
It exists so that the HTTP layer and the standalone workers share **one**
|
||||||
|
wiring: if the API and a worker ever disagreed about which adapter backs a
|
||||||
|
port, that would be a bug, and having two copies of the wiring is exactly how
|
||||||
|
such a bug appears.
|
||||||
|
|
||||||
|
Deliberately free of FastAPI: the HTTP dependency functions in
|
||||||
|
``app.interfaces.http.deps`` delegate here, and so do the workers in
|
||||||
|
``app.workers``. Nothing in the inner layers imports this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.billing_service import BillingService
|
||||||
|
from app.application.services.knowledge_service import KnowledgeService
|
||||||
|
from app.application.services.project_service import ProjectService
|
||||||
|
from app.application.services.sandbox_service import SandboxService
|
||||||
|
from app.application.services.workforce_service import WorkforceService
|
||||||
|
from app.infrastructure.adapters.registry import (
|
||||||
|
get_domain_router,
|
||||||
|
get_git_provider,
|
||||||
|
get_model_provider,
|
||||||
|
get_payment_provider,
|
||||||
|
get_sandbox_provider,
|
||||||
|
get_vector_store,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.ai_control import (
|
||||||
|
AiModelRepository,
|
||||||
|
ModelCredentialRepository,
|
||||||
|
RateCardRepository,
|
||||||
|
RoutingPolicyRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||||
|
from app.infrastructure.repositories.billing import (
|
||||||
|
CreditWalletRepository,
|
||||||
|
InvoiceRepository,
|
||||||
|
PaymentRepository,
|
||||||
|
PlanRepository,
|
||||||
|
SubscriptionRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.knowledge import (
|
||||||
|
DocumentChunkRepository,
|
||||||
|
DocumentRepository,
|
||||||
|
EmbeddingJobRepository,
|
||||||
|
KnowledgeSourceRepository,
|
||||||
|
RetrievalPolicyRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.projects import (
|
||||||
|
PreviewDomainRepository,
|
||||||
|
ProjectGitCredentialRepository,
|
||||||
|
ProjectGitRepoRepository,
|
||||||
|
ProjectRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.sandbox import (
|
||||||
|
CommandRunRepository,
|
||||||
|
PreviewUrlRepository,
|
||||||
|
ResourceLimitRepository,
|
||||||
|
SandboxSessionRepository,
|
||||||
|
SandboxTemplateRepository,
|
||||||
|
SnapshotRepository,
|
||||||
|
WorkspaceRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.workforce import (
|
||||||
|
AgentTemplateRepository,
|
||||||
|
ArtifactRepository,
|
||||||
|
EmployeeRepository,
|
||||||
|
EmployeeToolPermissionRepository,
|
||||||
|
MessageRepository,
|
||||||
|
PromptVersionRepository,
|
||||||
|
RunRepository,
|
||||||
|
RunStepRepository,
|
||||||
|
TaskRepository,
|
||||||
|
ThreadRepository,
|
||||||
|
ToolCallRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_project_service(session: AsyncSession) -> ProjectService:
|
||||||
|
"""Project + Git repo + preview domain, with the configured adapters."""
|
||||||
|
return ProjectService(
|
||||||
|
projects=ProjectRepository(session),
|
||||||
|
repos=ProjectGitRepoRepository(session),
|
||||||
|
credentials=ProjectGitCredentialRepository(session),
|
||||||
|
domains=PreviewDomainRepository(session),
|
||||||
|
git=get_git_provider(),
|
||||||
|
router=get_domain_router(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workforce_service(session: AsyncSession) -> WorkforceService:
|
||||||
|
"""Employees, threads, tasks and the run engine."""
|
||||||
|
return WorkforceService(
|
||||||
|
employees=EmployeeRepository(session),
|
||||||
|
templates=AgentTemplateRepository(session),
|
||||||
|
prompts=PromptVersionRepository(session),
|
||||||
|
permissions=EmployeeToolPermissionRepository(session),
|
||||||
|
threads=ThreadRepository(session),
|
||||||
|
messages=MessageRepository(session),
|
||||||
|
tasks=TaskRepository(session),
|
||||||
|
runs=RunRepository(session),
|
||||||
|
run_steps=RunStepRepository(session),
|
||||||
|
tool_calls=ToolCallRepository(session),
|
||||||
|
artifacts=ArtifactRepository(session),
|
||||||
|
models=AiModelRepository(session),
|
||||||
|
credentials=ModelCredentialRepository(session),
|
||||||
|
routing_policies=RoutingPolicyRepository(session),
|
||||||
|
rate_cards=RateCardRepository(session),
|
||||||
|
ledger=UsageLedgerRepository(session),
|
||||||
|
provider=get_model_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_billing_service(session: AsyncSession) -> BillingService:
|
||||||
|
"""Wallets, invoices, payments and the usage ledger."""
|
||||||
|
return BillingService(
|
||||||
|
wallets=CreditWalletRepository(session),
|
||||||
|
invoices=InvoiceRepository(session),
|
||||||
|
payments=PaymentRepository(session),
|
||||||
|
plans=PlanRepository(session),
|
||||||
|
subscriptions=SubscriptionRepository(session),
|
||||||
|
ledger=UsageLedgerRepository(session),
|
||||||
|
payment_provider=get_payment_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_knowledge_service(session: AsyncSession) -> KnowledgeService:
|
||||||
|
"""Knowledge sources, documents, chunks and the vector store."""
|
||||||
|
return KnowledgeService(
|
||||||
|
sources=KnowledgeSourceRepository(session),
|
||||||
|
documents=DocumentRepository(session),
|
||||||
|
chunks=DocumentChunkRepository(session),
|
||||||
|
jobs=EmbeddingJobRepository(session),
|
||||||
|
policies=RetrievalPolicyRepository(session),
|
||||||
|
vector_store=get_vector_store(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_sandbox_service(session: AsyncSession) -> SandboxService:
|
||||||
|
"""The sandbox gateway: the only sanctioned path for running code."""
|
||||||
|
return SandboxService(
|
||||||
|
templates=SandboxTemplateRepository(session),
|
||||||
|
workspaces=WorkspaceRepository(session),
|
||||||
|
sessions=SandboxSessionRepository(session),
|
||||||
|
commands=CommandRunRepository(session),
|
||||||
|
snapshots=SnapshotRepository(session),
|
||||||
|
previews=PreviewUrlRepository(session),
|
||||||
|
limits=ResourceLimitRepository(session),
|
||||||
|
rate_cards=RateCardRepository(session),
|
||||||
|
ledger=UsageLedgerRepository(session),
|
||||||
|
projects=ProjectRepository(session),
|
||||||
|
provider=get_sandbox_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_billing_service",
|
||||||
|
"build_knowledge_service",
|
||||||
|
"build_project_service",
|
||||||
|
"build_sandbox_service",
|
||||||
|
"build_workforce_service",
|
||||||
|
]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Application-layer ports (driven interfaces).
|
||||||
|
|
||||||
|
Every port has at least one *mock* adapter so the whole stack runs with zero
|
||||||
|
external credentials. Concrete adapters live in ``app.infrastructure.adapters``
|
||||||
|
and are selected by the ``*_PROVIDER`` environment switches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.application.ports.audit import (
|
||||||
|
AuditPort,
|
||||||
|
UsageAggregate,
|
||||||
|
UsageBreakdown,
|
||||||
|
UsageLedgerPort,
|
||||||
|
UsageRecord,
|
||||||
|
)
|
||||||
|
from app.application.ports.domain_router import (
|
||||||
|
DomainRouterPort,
|
||||||
|
PreviewDomainInfo,
|
||||||
|
ReverseProxyResult,
|
||||||
|
)
|
||||||
|
from app.application.ports.git_provider import (
|
||||||
|
BranchInfo,
|
||||||
|
CommitInfo,
|
||||||
|
GitCredentials,
|
||||||
|
GitProviderPort,
|
||||||
|
MergeResult,
|
||||||
|
PullRequestInfo,
|
||||||
|
RepoInfo,
|
||||||
|
RepoStatus,
|
||||||
|
)
|
||||||
|
from app.application.ports.model_provider import (
|
||||||
|
ChatMessage,
|
||||||
|
ModelHealth,
|
||||||
|
ModelProviderPort,
|
||||||
|
ModelResponse,
|
||||||
|
)
|
||||||
|
from app.application.ports.payment_provider import (
|
||||||
|
CheckoutSession,
|
||||||
|
PaymentProviderPort,
|
||||||
|
PaymentResult,
|
||||||
|
)
|
||||||
|
from app.application.ports.sandbox_provider import (
|
||||||
|
CommandResult,
|
||||||
|
SandboxProviderPort,
|
||||||
|
SessionInfo,
|
||||||
|
SnapshotInfo,
|
||||||
|
WorkspaceInfo,
|
||||||
|
)
|
||||||
|
from app.application.ports.storage import (
|
||||||
|
Notification,
|
||||||
|
NotificationPort,
|
||||||
|
StoragePort,
|
||||||
|
StoredObject,
|
||||||
|
VectorHit,
|
||||||
|
VectorStorePort,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditPort",
|
||||||
|
"BranchInfo",
|
||||||
|
"ChatMessage",
|
||||||
|
"CheckoutSession",
|
||||||
|
"CommandResult",
|
||||||
|
"CommitInfo",
|
||||||
|
"DomainRouterPort",
|
||||||
|
"GitCredentials",
|
||||||
|
"GitProviderPort",
|
||||||
|
"MergeResult",
|
||||||
|
"ModelHealth",
|
||||||
|
"ModelProviderPort",
|
||||||
|
"ModelResponse",
|
||||||
|
"Notification",
|
||||||
|
"NotificationPort",
|
||||||
|
"PaymentProviderPort",
|
||||||
|
"PaymentResult",
|
||||||
|
"PreviewDomainInfo",
|
||||||
|
"PullRequestInfo",
|
||||||
|
"RepoInfo",
|
||||||
|
"RepoStatus",
|
||||||
|
"ReverseProxyResult",
|
||||||
|
"SandboxProviderPort",
|
||||||
|
"SessionInfo",
|
||||||
|
"SnapshotInfo",
|
||||||
|
"StoragePort",
|
||||||
|
"StoredObject",
|
||||||
|
"UsageAggregate",
|
||||||
|
"UsageBreakdown",
|
||||||
|
"UsageLedgerPort",
|
||||||
|
"UsageRecord",
|
||||||
|
"VectorHit",
|
||||||
|
"VectorStorePort",
|
||||||
|
"WorkspaceInfo",
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Audit and usage-ledger ports.
|
||||||
|
|
||||||
|
``AuditPort`` writes the immutable trail; ``UsageLedgerPort`` is the billing
|
||||||
|
source of truth. Both are append-only by contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from app.domain.ai_control.models import RateUnit
|
||||||
|
from app.domain.audit.models import AuditEvent
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class UsageRecord:
|
||||||
|
organization_id: str
|
||||||
|
unit: RateUnit
|
||||||
|
quantity: float
|
||||||
|
unit_price: float = 0.0
|
||||||
|
internal_cost_estimate: float = 0.0
|
||||||
|
total_price: float = 0.0
|
||||||
|
project_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
employee_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
metadata_json: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class UsageAggregate:
|
||||||
|
unit: RateUnit
|
||||||
|
quantity: float = 0.0
|
||||||
|
total_price: float = 0.0
|
||||||
|
internal_cost_estimate: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class UsageBreakdown:
|
||||||
|
"""Grouped totals for the billing dashboard."""
|
||||||
|
|
||||||
|
by_unit: list[UsageAggregate] = field(default_factory=list)
|
||||||
|
by_employee: dict[str, float] = field(default_factory=dict)
|
||||||
|
by_project: dict[str, float] = field(default_factory=dict)
|
||||||
|
total_price: float = 0.0
|
||||||
|
total_cost: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AuditPort(Protocol):
|
||||||
|
async def record(self, *, event: AuditEvent) -> AuditEvent: ...
|
||||||
|
|
||||||
|
async def list_for_organization(
|
||||||
|
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[AuditEvent]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class UsageLedgerPort(Protocol):
|
||||||
|
async def record(self, *, usage: UsageRecord) -> None:
|
||||||
|
"""Append one usage event. Never raises for business reasons — a failed
|
||||||
|
ledger write is an infrastructure error and must surface loudly."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def breakdown(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> UsageBreakdown: ...
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Domain router port — preview subdomains and the port ledger.
|
||||||
|
|
||||||
|
Security rules carried over from the current platform (see
|
||||||
|
``docs/current-infra-notes.md``) are enforced by the *adapters* as well as the
|
||||||
|
application layer:
|
||||||
|
|
||||||
|
* single-label hosts under ``PREVIEW_ROOT_DOMAIN`` only
|
||||||
|
* label charset ``[a-z0-9-]``, 3-40 chars
|
||||||
|
* proxy target must be ``localhost`` / ``127.0.0.1`` with a numeric port
|
||||||
|
* protected platform ports are never allocatable or proxied to
|
||||||
|
* the Caddy config is validated before it is reloaded, and rolled back on failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class PreviewDomainInfo:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
url: str = ""
|
||||||
|
protected: bool = False
|
||||||
|
status: str = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class ReverseProxyResult:
|
||||||
|
ok: bool
|
||||||
|
message: str = ""
|
||||||
|
url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DomainRouterPort(Protocol):
|
||||||
|
async def allocate_port(
|
||||||
|
self, *, organization_id: str, project_id: str, service_name: str
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
async def claim_preview_domain(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
project_id: str,
|
||||||
|
requested_subdomain: str,
|
||||||
|
port: int,
|
||||||
|
) -> PreviewDomainInfo: ...
|
||||||
|
|
||||||
|
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult: ...
|
||||||
|
|
||||||
|
async def remove_preview_domain(self, *, domain_id: str) -> None: ...
|
||||||
|
|
||||||
|
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]: ...
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Git provider port — repository lifecycle without leaking the admin token."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class RepoInfo:
|
||||||
|
owner: str
|
||||||
|
repo_name: str
|
||||||
|
clone_url: str = ""
|
||||||
|
web_url: str = ""
|
||||||
|
default_branch: str = "main"
|
||||||
|
private: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class BranchInfo:
|
||||||
|
name: str
|
||||||
|
sha: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class CommitInfo:
|
||||||
|
sha: str
|
||||||
|
branch: str
|
||||||
|
message: str = ""
|
||||||
|
files_changed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class PullRequestInfo:
|
||||||
|
number: int
|
||||||
|
url: str
|
||||||
|
state: str = "open"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class MergeResult:
|
||||||
|
merged: bool
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class RepoStatus:
|
||||||
|
exists: bool
|
||||||
|
private: bool = True
|
||||||
|
default_branch: str = "main"
|
||||||
|
branches: list[str] = field(default_factory=list)
|
||||||
|
open_pull_requests: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class GitCredentials:
|
||||||
|
"""Returned only to the application layer. Never serialised to a response."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class GitProviderPort(Protocol):
|
||||||
|
"""Repository lifecycle.
|
||||||
|
|
||||||
|
Identity is passed in, never guessed. A real forge (Gitea) addresses
|
||||||
|
repositories as ``owner/repo``; the platform stores that pair in
|
||||||
|
``ProjectRepository``. Passing it down keeps the adapter stateless — it holds
|
||||||
|
no ``project_id`` mapping that would be lost on restart and would drift from
|
||||||
|
the database.
|
||||||
|
|
||||||
|
The mock adapter ignores ``owner``/``repo_name`` because it keys everything
|
||||||
|
by ``project_id``; the defaults keep that adapter valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def ensure_project_repo(
|
||||||
|
self, *, project_id: str, repo_name: str, description: str = ""
|
||||||
|
) -> RepoInfo: ...
|
||||||
|
|
||||||
|
async def ensure_credentials(
|
||||||
|
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||||
|
) -> GitCredentials: ...
|
||||||
|
|
||||||
|
async def create_branch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
branch_name: str,
|
||||||
|
from_branch: str,
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> BranchInfo: ...
|
||||||
|
|
||||||
|
async def commit_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
branch_name: str,
|
||||||
|
files: dict[str, str],
|
||||||
|
message: str,
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> CommitInfo: ...
|
||||||
|
|
||||||
|
async def open_pull_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
head: str,
|
||||||
|
base: str,
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> PullRequestInfo: ...
|
||||||
|
|
||||||
|
async def merge_pull_request(
|
||||||
|
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||||
|
) -> MergeResult: ...
|
||||||
|
|
||||||
|
async def get_repo_status(
|
||||||
|
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||||
|
) -> RepoStatus: ...
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Model provider port — the only sanctioned way to reach an LLM.
|
||||||
|
|
||||||
|
The adapter decides which concrete provider/model is used; the caller passes an
|
||||||
|
``AiModel`` record and never a provider API key. That keeps model identity
|
||||||
|
inside the AI Control Plane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class ChatMessage:
|
||||||
|
role: str # system | user | assistant | tool
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class ModelResponse:
|
||||||
|
content: str
|
||||||
|
model_internal_name: str = ""
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
latency_ms: int = 0
|
||||||
|
raw: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class ModelHealth:
|
||||||
|
ok: bool
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ModelProviderPort(Protocol):
|
||||||
|
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
|
||||||
|
``OpenAICompatibleModelAdapter``."""
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: AiModel,
|
||||||
|
credential: ModelCredential | None,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
system: str = "",
|
||||||
|
temperature: float = 0.2,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
) -> ModelResponse:
|
||||||
|
"""Run one completion. Must raise ``ProviderUnavailableError`` on failure."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def health(self) -> ModelHealth:
|
||||||
|
...
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Payment provider port — an adapter over Stripe (or mock).
|
||||||
|
|
||||||
|
The internal usage ledger is the source of truth; this port only moves money.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class CheckoutSession:
|
||||||
|
external_id: str
|
||||||
|
url: str
|
||||||
|
amount: float = 0.0
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class PaymentResult:
|
||||||
|
ok: bool
|
||||||
|
external_id: str = ""
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PaymentProviderPort(Protocol):
|
||||||
|
async def create_checkout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
amount: float,
|
||||||
|
currency: str,
|
||||||
|
success_url: str,
|
||||||
|
cancel_url: str,
|
||||||
|
) -> CheckoutSession: ...
|
||||||
|
|
||||||
|
async def refund(
|
||||||
|
self, *, external_payment_id: str, amount: float | None = None
|
||||||
|
) -> PaymentResult: ...
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Sandbox provider port — the only path that may execute tenant code.
|
||||||
|
|
||||||
|
Implementations: ``LocalMockSandboxAdapter`` (default, executes nothing) and
|
||||||
|
``DaytonaSandboxAdapter`` / ``E2BSandboxAdapter`` behind ``SANDBOX_PROVIDER``.
|
||||||
|
|
||||||
|
Every method that runs something takes an explicit timeout. Callers must never
|
||||||
|
be able to request "no limit".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class WorkspaceInfo:
|
||||||
|
external_id: str
|
||||||
|
status: str = "ready"
|
||||||
|
raw: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class SessionInfo:
|
||||||
|
external_id: str
|
||||||
|
status: str = "running"
|
||||||
|
raw: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class CommandResult:
|
||||||
|
exit_code: int
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
duration_ms: int = 0
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class SnapshotInfo:
|
||||||
|
external_id: str
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SandboxProviderPort(Protocol):
|
||||||
|
async def create_workspace(
|
||||||
|
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||||
|
) -> WorkspaceInfo: ...
|
||||||
|
|
||||||
|
async def start_session(self, *, workspace_external_id: str) -> SessionInfo: ...
|
||||||
|
|
||||||
|
async def run_command(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_external_id: str,
|
||||||
|
command: str,
|
||||||
|
timeout_seconds: int,
|
||||||
|
max_output_bytes: int,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> CommandResult: ...
|
||||||
|
|
||||||
|
async def read_file(self, *, session_external_id: str, path: str) -> str: ...
|
||||||
|
|
||||||
|
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None: ...
|
||||||
|
|
||||||
|
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo: ...
|
||||||
|
|
||||||
|
async def pause(self, *, session_external_id: str) -> None: ...
|
||||||
|
|
||||||
|
async def resume(self, *, session_external_id: str) -> SessionInfo: ...
|
||||||
|
|
||||||
|
async def destroy(self, *, session_external_id: str) -> None: ...
|
||||||
|
|
||||||
|
async def get_preview_url(self, *, session_external_id: str, port: int) -> str: ...
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Storage, vector store and notification ports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class StoredObject:
|
||||||
|
key: str
|
||||||
|
size_bytes: int = 0
|
||||||
|
url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class StoragePort(Protocol):
|
||||||
|
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject: ...
|
||||||
|
|
||||||
|
async def get(self, *, key: str) -> bytes: ...
|
||||||
|
|
||||||
|
async def delete(self, *, key: str) -> None: ...
|
||||||
|
|
||||||
|
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class VectorHit:
|
||||||
|
chunk_id: str
|
||||||
|
document_id: str
|
||||||
|
content: str
|
||||||
|
score: float = 0.0
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class VectorStorePort(Protocol):
|
||||||
|
async def upsert(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
chunks: list[tuple[str, str, list[float]]],
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
query: str,
|
||||||
|
limit: int = 5,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> list[VectorHit]: ...
|
||||||
|
|
||||||
|
async def delete_document(self, *, document_id: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
class Notification:
|
||||||
|
kind: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
to: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class NotificationPort(Protocol):
|
||||||
|
async def send(self, *, notification: Notification) -> bool: ...
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Audit helper used by the HTTP layer.
|
||||||
|
|
||||||
|
Every sensitive operation calls this. It is deliberately cheap (one INSERT) so
|
||||||
|
there is no excuse to skip it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.audit.models import AuditEvent
|
||||||
|
from app.infrastructure.repositories.audit import AuditRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def record_audit(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
target_type: str = "",
|
||||||
|
target_id: str = "",
|
||||||
|
metadata: dict | None = None,
|
||||||
|
ip: str = "",
|
||||||
|
user_agent: str = "",
|
||||||
|
) -> AuditEvent:
|
||||||
|
event = AuditEvent(
|
||||||
|
action=action,
|
||||||
|
organization_id=organization_id,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
metadata_json=metadata or {},
|
||||||
|
ip_address=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
return await AuditRepository(session).record(event=event)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Billing use cases.
|
||||||
|
|
||||||
|
Design rule, stated once and enforced here: **the internal usage ledger is the
|
||||||
|
source of truth.** A payment provider only moves money; it never decides what a
|
||||||
|
tenant owes. That is why ``summary`` computes totals from ``UsageLedgerRepository``
|
||||||
|
and not from any external invoice.
|
||||||
|
|
||||||
|
Credit is spent, not checked-then-spent: the debit is a single adjustment, so two
|
||||||
|
concurrent runs cannot both pass a stale affordability check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.application.ports import PaymentProviderPort
|
||||||
|
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||||
|
from app.domain.billing.models import (
|
||||||
|
CreditWallet,
|
||||||
|
Invoice,
|
||||||
|
Payment,
|
||||||
|
PaymentStatus,
|
||||||
|
Subscription,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||||
|
from app.infrastructure.repositories.billing import (
|
||||||
|
CreditWalletRepository,
|
||||||
|
InvoiceRepository,
|
||||||
|
PaymentRepository,
|
||||||
|
PlanRepository,
|
||||||
|
SubscriptionRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: A tenant may not buy an arbitrary amount of credit in one action.
|
||||||
|
MIN_TOPUP = 1.0
|
||||||
|
MAX_TOPUP = 10_000.0
|
||||||
|
|
||||||
|
|
||||||
|
class BillingService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
wallets: CreditWalletRepository,
|
||||||
|
invoices: InvoiceRepository,
|
||||||
|
payments: PaymentRepository,
|
||||||
|
plans: PlanRepository,
|
||||||
|
subscriptions: SubscriptionRepository,
|
||||||
|
ledger: UsageLedgerRepository,
|
||||||
|
payment_provider: PaymentProviderPort,
|
||||||
|
) -> None:
|
||||||
|
self.wallets = wallets
|
||||||
|
self.invoices = invoices
|
||||||
|
self.payments = payments
|
||||||
|
self.plans = plans
|
||||||
|
self.subscriptions = subscriptions
|
||||||
|
self.ledger = ledger
|
||||||
|
self.payment_provider = payment_provider
|
||||||
|
|
||||||
|
# -- reads ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def wallet_for(self, organization_id: str) -> CreditWallet:
|
||||||
|
return await self.wallets.ensure(organization_id)
|
||||||
|
|
||||||
|
async def summary(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
wallet = await self.wallets.ensure(organization_id)
|
||||||
|
breakdown = await self.ledger.breakdown(
|
||||||
|
organization_id=organization_id, since=since, until=until
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"balance": wallet.balance,
|
||||||
|
"currency": wallet.currency,
|
||||||
|
"low_balance": wallet.is_low,
|
||||||
|
"low_balance_threshold": wallet.low_balance_threshold,
|
||||||
|
"total_spend": breakdown.total_price,
|
||||||
|
"by_unit": breakdown.by_unit,
|
||||||
|
"by_employee": breakdown.by_employee,
|
||||||
|
"by_project": breakdown.by_project,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_usage_events(
|
||||||
|
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||||
|
) -> list:
|
||||||
|
return await self.ledger.list_events(
|
||||||
|
organization_id=organization_id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_invoices(self, organization_id: str) -> list[Invoice]:
|
||||||
|
return await self.invoices.list_for_org(organization_id)
|
||||||
|
|
||||||
|
async def list_payments(self, organization_id: str) -> list[Payment]:
|
||||||
|
return await self.payments.list_for_org(organization_id)
|
||||||
|
|
||||||
|
async def current_subscription(self, organization_id: str) -> Subscription | None:
|
||||||
|
return await self.subscriptions.get_for_org(organization_id)
|
||||||
|
|
||||||
|
# -- writes --------------------------------------------------------------
|
||||||
|
|
||||||
|
async def add_credit(
|
||||||
|
self, *, organization_id: str, amount: float, allow_negative: bool = False
|
||||||
|
) -> CreditWallet:
|
||||||
|
"""Grant (or, for a platform owner, claw back) credit.
|
||||||
|
|
||||||
|
``allow_negative`` is reserved for the platform owner console; an org
|
||||||
|
admin may only ever add credit.
|
||||||
|
"""
|
||||||
|
if amount == 0:
|
||||||
|
raise ValidationError("مقدار اعتبار نمیتواند صفر باشد.")
|
||||||
|
if amount < 0 and not allow_negative:
|
||||||
|
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
||||||
|
if not allow_negative and amount > MAX_TOPUP:
|
||||||
|
raise ValidationError(f"حداکثر افزایش اعتبار در هر عملیات {MAX_TOPUP} است.")
|
||||||
|
|
||||||
|
wallet = await self.wallets.adjust(organization_id=organization_id, delta=amount)
|
||||||
|
if wallet.balance < 0:
|
||||||
|
# Refuse to leave a wallet in debt; undo and surface the conflict.
|
||||||
|
await self.wallets.adjust(organization_id=organization_id, delta=-amount)
|
||||||
|
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
async def start_checkout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
amount: float,
|
||||||
|
success_url: str,
|
||||||
|
cancel_url: str,
|
||||||
|
) -> tuple[Payment, str]:
|
||||||
|
"""Create a pending payment plus the provider's hosted checkout URL."""
|
||||||
|
if amount < MIN_TOPUP:
|
||||||
|
raise ValidationError(f"حداقل مبلغ شارژ {MIN_TOPUP} است.")
|
||||||
|
if amount > MAX_TOPUP:
|
||||||
|
raise ValidationError(f"حداکثر مبلغ شارژ {MAX_TOPUP} است.")
|
||||||
|
|
||||||
|
wallet = await self.wallets.ensure(organization_id)
|
||||||
|
payment = await self.payments.create(
|
||||||
|
Payment(
|
||||||
|
organization_id=organization_id,
|
||||||
|
amount=amount,
|
||||||
|
status=PaymentStatus.PENDING,
|
||||||
|
provider="mock",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = await self.payment_provider.create_checkout(
|
||||||
|
organization_id=organization_id,
|
||||||
|
amount=amount,
|
||||||
|
currency=wallet.currency,
|
||||||
|
success_url=success_url,
|
||||||
|
cancel_url=cancel_url,
|
||||||
|
)
|
||||||
|
updated = await self.payments.set_status(
|
||||||
|
payment.id, PaymentStatus.PENDING, external_id=session.external_id
|
||||||
|
)
|
||||||
|
return (updated or payment), session.url
|
||||||
|
|
||||||
|
async def settle_payment(
|
||||||
|
self, *, organization_id: str, payment_id: str, succeeded: bool
|
||||||
|
) -> Payment:
|
||||||
|
"""Mark a payment final and credit the wallet when it succeeded.
|
||||||
|
|
||||||
|
This is the only path that turns money into credit, so it is idempotent
|
||||||
|
with respect to status: a payment that is already final is returned
|
||||||
|
untouched rather than credited twice.
|
||||||
|
"""
|
||||||
|
payment = next(
|
||||||
|
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if payment is None:
|
||||||
|
raise NotFoundError("پرداخت یافت نشد.")
|
||||||
|
if payment.status != PaymentStatus.PENDING:
|
||||||
|
return payment
|
||||||
|
|
||||||
|
if succeeded:
|
||||||
|
await self.wallets.adjust(organization_id=organization_id, delta=payment.amount)
|
||||||
|
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
|
||||||
|
else:
|
||||||
|
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
|
||||||
|
return final or payment
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Knowledge Base use cases: ingest text, chunk it, and retrieve it safely.
|
||||||
|
|
||||||
|
Two things matter here:
|
||||||
|
|
||||||
|
* **Chunking is deterministic.** The same text always produces the same chunks,
|
||||||
|
which makes ingestion idempotent-ish and the tests meaningful.
|
||||||
|
* **Retrieval is permission-aware.** Search is always scoped to one organization,
|
||||||
|
and optionally narrowed to one project. An org-wide source is visible to every
|
||||||
|
project; a project-scoped source is not visible from another project.
|
||||||
|
|
||||||
|
Embeddings are computed through ``VectorStorePort`` when a real adapter is
|
||||||
|
configured. With the mock adapter no vectors are produced and retrieval falls
|
||||||
|
back to keyword matching — the same fallback the plan calls for at MVP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.application.ports import VectorStorePort
|
||||||
|
from app.core.errors import NotFoundError, ValidationError
|
||||||
|
from app.domain.knowledge.models import (
|
||||||
|
Document,
|
||||||
|
DocumentChunk,
|
||||||
|
EmbeddingJob,
|
||||||
|
EmbeddingJobStatus,
|
||||||
|
KnowledgeSource,
|
||||||
|
RetrievalPolicy,
|
||||||
|
SourceKind,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.knowledge import (
|
||||||
|
DocumentChunkRepository,
|
||||||
|
DocumentRepository,
|
||||||
|
EmbeddingJobRepository,
|
||||||
|
KnowledgeSourceRepository,
|
||||||
|
RetrievalPolicyRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Chunk size in words. Small enough to keep a retrieved passage focused.
|
||||||
|
CHUNK_WORDS = 180
|
||||||
|
CHUNK_OVERLAP_WORDS = 30
|
||||||
|
MAX_INGEST_CHARS = 400_000
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(
|
||||||
|
text: str, *, size: int = CHUNK_WORDS, overlap: int = CHUNK_OVERLAP_WORDS
|
||||||
|
) -> list[str]:
|
||||||
|
"""Split text into overlapping word windows.
|
||||||
|
|
||||||
|
Pure and deterministic — no regex surprises, no dependence on punctuation.
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
if size <= 0:
|
||||||
|
raise ValueError("chunk size must be positive")
|
||||||
|
step = max(1, size - max(0, overlap))
|
||||||
|
|
||||||
|
chunks: list[str] = []
|
||||||
|
for start in range(0, len(words), step):
|
||||||
|
window = words[start : start + size]
|
||||||
|
if not window:
|
||||||
|
break
|
||||||
|
chunks.append(" ".join(window))
|
||||||
|
if start + size >= len(words):
|
||||||
|
break
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sources: KnowledgeSourceRepository,
|
||||||
|
documents: DocumentRepository,
|
||||||
|
chunks: DocumentChunkRepository,
|
||||||
|
jobs: EmbeddingJobRepository,
|
||||||
|
policies: RetrievalPolicyRepository,
|
||||||
|
vector_store: VectorStorePort,
|
||||||
|
) -> None:
|
||||||
|
self.sources = sources
|
||||||
|
self.documents = documents
|
||||||
|
self.chunks = chunks
|
||||||
|
self.jobs = jobs
|
||||||
|
self.policies = policies
|
||||||
|
self.vector_store = vector_store
|
||||||
|
|
||||||
|
# -- ingestion -----------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_text_source(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
project_id: str | None = None,
|
||||||
|
title: str = "",
|
||||||
|
) -> tuple[KnowledgeSource, int]:
|
||||||
|
if not name.strip():
|
||||||
|
raise ValidationError("نام منبع دانش نمیتواند خالی باشد.")
|
||||||
|
if not content.strip():
|
||||||
|
raise ValidationError("محتوای منبع دانش نمیتواند خالی باشد.")
|
||||||
|
if len(content) > MAX_INGEST_CHARS:
|
||||||
|
raise ValidationError("حجم محتوا بیش از حد مجاز است.")
|
||||||
|
|
||||||
|
source = await self.sources.create(
|
||||||
|
KnowledgeSource(
|
||||||
|
organization_id=organization_id,
|
||||||
|
name=name.strip(),
|
||||||
|
kind=SourceKind.TEXT,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document = await self.documents.create(
|
||||||
|
Document(source_id=source.id, title=title.strip() or source.name, content=content)
|
||||||
|
)
|
||||||
|
|
||||||
|
pieces = chunk_text(content)
|
||||||
|
chunk_rows = [
|
||||||
|
DocumentChunk(document_id=document.id, index=index, content=piece)
|
||||||
|
for index, piece in enumerate(pieces)
|
||||||
|
]
|
||||||
|
created = await self.chunks.create_many(chunk_rows)
|
||||||
|
|
||||||
|
job = await self.jobs.create(EmbeddingJob(source_id=source.id))
|
||||||
|
try:
|
||||||
|
# The mock vector store is a no-op that accepts and discards; a real
|
||||||
|
# adapter embeds here. Keyword search below works either way.
|
||||||
|
await self.vector_store.upsert(
|
||||||
|
organization_id=organization_id,
|
||||||
|
chunks=[(chunk.id, chunk.content, []) for chunk in chunk_rows],
|
||||||
|
metadata={"source_id": source.id, "project_id": project_id},
|
||||||
|
)
|
||||||
|
await self.jobs.set_status(job.id, EmbeddingJobStatus.DONE)
|
||||||
|
except Exception as exc: # noqa: BLE001 - embedding is best-effort at MVP
|
||||||
|
# Ingestion still succeeded; only the vector index is degraded, and
|
||||||
|
# keyword search keeps working. Record why rather than failing hard.
|
||||||
|
await self.jobs.set_status(job.id, EmbeddingJobStatus.FAILED, error=str(exc))
|
||||||
|
|
||||||
|
return source, created
|
||||||
|
|
||||||
|
# -- reads ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_sources(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[KnowledgeSource]:
|
||||||
|
return await self.sources.list_for_org(organization_id, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
async def get_source_or_404(
|
||||||
|
self, *, organization_id: str, source_id: str
|
||||||
|
) -> KnowledgeSource:
|
||||||
|
source = await self.sources.get_for_org(organization_id, source_id)
|
||||||
|
if source is None:
|
||||||
|
raise NotFoundError("منبع دانش یافت نشد.")
|
||||||
|
return source
|
||||||
|
|
||||||
|
async def source_detail(self, *, source_id: str) -> dict:
|
||||||
|
documents = await self.documents.list_for_source(source_id)
|
||||||
|
return {
|
||||||
|
"documents": documents,
|
||||||
|
"chunk_count": await self.chunks.count_for_source(source_id),
|
||||||
|
"job": await self.jobs.latest_for_source(source_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def chunk_counts(self, source_ids: list[str]) -> dict[str, int]:
|
||||||
|
"""Chunk counts for many sources at once — see ``count_for_sources``."""
|
||||||
|
return await self.chunks.count_for_sources(source_ids)
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
query: str,
|
||||||
|
project_id: str | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
return await self.chunks.keyword_search(
|
||||||
|
organization_id=organization_id,
|
||||||
|
query=query,
|
||||||
|
project_id=project_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_source(self, *, organization_id: str, source_id: str) -> None:
|
||||||
|
source = await self.get_source_or_404(
|
||||||
|
organization_id=organization_id, source_id=source_id
|
||||||
|
)
|
||||||
|
await self.sources.delete(source.id)
|
||||||
|
|
||||||
|
# -- retrieval policies --------------------------------------------------
|
||||||
|
|
||||||
|
async def list_policies(self, organization_id: str) -> list[RetrievalPolicy]:
|
||||||
|
return await self.policies.list_for_org(organization_id)
|
||||||
|
|
||||||
|
async def create_policy(
|
||||||
|
self, *, organization_id: str, name: str, scope: dict | None = None
|
||||||
|
) -> RetrievalPolicy:
|
||||||
|
if not name.strip():
|
||||||
|
raise ValidationError("نام سیاست بازیابی نمیتواند خالی باشد.")
|
||||||
|
return await self.policies.create(
|
||||||
|
RetrievalPolicy(
|
||||||
|
organization_id=organization_id, name=name.strip(), scope_json=dict(scope or {})
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Project and preview-domain use cases.
|
||||||
|
|
||||||
|
This is the application layer: it orchestrates domain rules, repositories and the
|
||||||
|
driven ports (Git, domain router). It knows nothing about HTTP.
|
||||||
|
|
||||||
|
Security properties enforced here (not in the router, not only in the adapter):
|
||||||
|
|
||||||
|
* a project repository is always created **private**
|
||||||
|
* the repository name is collision-free across tenants
|
||||||
|
* the per-project Git password is encrypted before it is stored and never
|
||||||
|
returned to a caller
|
||||||
|
* a preview host must be a single label under the configured root domain
|
||||||
|
* a preview host is unique platform-wide
|
||||||
|
* the proxy target port must be allocatable and must not be a protected
|
||||||
|
platform port
|
||||||
|
* a project may hold at most ``max_subdomains_per_project`` hosts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.application.ports import (
|
||||||
|
DomainRouterPort,
|
||||||
|
GitCredentials,
|
||||||
|
GitProviderPort,
|
||||||
|
RepoInfo,
|
||||||
|
RepoStatus,
|
||||||
|
)
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.crypto import decrypt, encrypt
|
||||||
|
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
|
||||||
|
from app.domain.base import ensure_unique_slug, slugify
|
||||||
|
from app.domain.platform.rules import (
|
||||||
|
assert_not_protected_port,
|
||||||
|
assert_port_allocatable,
|
||||||
|
build_host,
|
||||||
|
validate_subdomain_label,
|
||||||
|
)
|
||||||
|
from app.domain.projects.models import (
|
||||||
|
Project,
|
||||||
|
ProjectGitCredential,
|
||||||
|
ProjectGitRepo,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.projects import (
|
||||||
|
PreviewDomainRepository,
|
||||||
|
ProjectGitCredentialRepository,
|
||||||
|
ProjectGitRepoRepository,
|
||||||
|
ProjectRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
projects: ProjectRepository,
|
||||||
|
repos: ProjectGitRepoRepository,
|
||||||
|
credentials: ProjectGitCredentialRepository,
|
||||||
|
domains: PreviewDomainRepository,
|
||||||
|
git: GitProviderPort,
|
||||||
|
router: DomainRouterPort,
|
||||||
|
) -> None:
|
||||||
|
self.projects = projects
|
||||||
|
self.repos = repos
|
||||||
|
self.credentials = credentials
|
||||||
|
self.domains = domains
|
||||||
|
self.git = git
|
||||||
|
self.router = router
|
||||||
|
|
||||||
|
# -- projects ------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_project(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
organization_slug: str,
|
||||||
|
created_by_user_id: str,
|
||||||
|
name: str,
|
||||||
|
description: str = "",
|
||||||
|
) -> Project:
|
||||||
|
slug = ensure_unique_slug(slugify(name), await self.projects.list_slugs(organization_id))
|
||||||
|
return await self.projects.create(
|
||||||
|
Project(
|
||||||
|
organization_id=organization_id,
|
||||||
|
name=name.strip(),
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_or_404(self, *, organization_id: str, project_id: str) -> Project:
|
||||||
|
project = await self.projects.get(project_id)
|
||||||
|
# Returning 404 (not 403) keeps the existence of other tenants' projects
|
||||||
|
# out of the response.
|
||||||
|
if project is None or project.organization_id != organization_id:
|
||||||
|
raise NotFoundError("پروژه یافت نشد.")
|
||||||
|
return project
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def repo_name_for(*, organization_slug: str, project: Project) -> str:
|
||||||
|
"""``orgslug-projectslug-shortid`` — stable and collision-free."""
|
||||||
|
digest = hashlib.sha1(f"{organization_slug}:{project.id}".encode()).hexdigest()[:6]
|
||||||
|
raw = f"{organization_slug}-{project.slug}-{digest}"
|
||||||
|
return raw[:100].strip("-")
|
||||||
|
|
||||||
|
# -- git -----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def ensure_repository(
|
||||||
|
self, *, organization_slug: str, project: Project
|
||||||
|
) -> ProjectGitRepo:
|
||||||
|
repo_name = self.repo_name_for(organization_slug=organization_slug, project=project)
|
||||||
|
info: RepoInfo = await self.git.ensure_project_repo(
|
||||||
|
project_id=project.id,
|
||||||
|
repo_name=repo_name,
|
||||||
|
description=f"مخزن پروژهٔ {project.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
credentials: GitCredentials = await self.git.ensure_credentials(
|
||||||
|
project_id=project.id, owner=info.owner, repo_name=info.repo_name
|
||||||
|
)
|
||||||
|
await self.credentials.upsert(
|
||||||
|
ProjectGitCredential(
|
||||||
|
project_id=project.id,
|
||||||
|
username=credentials.username,
|
||||||
|
# Encrypted at rest. The plaintext only exists inside this method.
|
||||||
|
encrypted_password=encrypt(credentials.password),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.repos.upsert(
|
||||||
|
ProjectGitRepo(
|
||||||
|
project_id=project.id,
|
||||||
|
provider=settings.git_provider,
|
||||||
|
owner=info.owner,
|
||||||
|
repo_name=info.repo_name,
|
||||||
|
clone_url=info.clone_url,
|
||||||
|
web_url=info.web_url,
|
||||||
|
default_branch=info.default_branch,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def repo_status(self, *, project: Project) -> RepoStatus:
|
||||||
|
record = await self.repos.get_for_project(project.id)
|
||||||
|
if record is None:
|
||||||
|
return RepoStatus(exists=False)
|
||||||
|
# Identity comes from our own record so the adapter stays stateless.
|
||||||
|
return await self.git.get_repo_status(
|
||||||
|
project_id=project.id, owner=record.owner, repo_name=record.repo_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def clone_credentials(self, *, project: Project) -> tuple[str, str]:
|
||||||
|
"""``(username, password)`` for a one-off clone.
|
||||||
|
|
||||||
|
Only the application layer may call this, and the router must never
|
||||||
|
serialise the result.
|
||||||
|
"""
|
||||||
|
record = await self.credentials.get_for_project(project.id)
|
||||||
|
if record is None:
|
||||||
|
raise NotFoundError("اطلاعات دسترسی مخزن ثبت نشده است.")
|
||||||
|
return record.username, decrypt(record.encrypted_password)
|
||||||
|
|
||||||
|
# -- preview domains -----------------------------------------------------
|
||||||
|
|
||||||
|
async def allocate_port(self, *, project: Project, service_name: str = "web") -> int:
|
||||||
|
existing = await self.domains.get_port(project.id, service_name)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
start, end = settings.preview_port_start, settings.preview_port_end
|
||||||
|
used = await self.domains.list_used_ports(start=start, end=end)
|
||||||
|
for port in range(start, end + 1):
|
||||||
|
if port in used:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
assert_port_allocatable(port, start=start, end=end)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
await self.domains.add_port(
|
||||||
|
organization_id=project.organization_id,
|
||||||
|
project_id=project.id,
|
||||||
|
service_name=service_name,
|
||||||
|
port=port,
|
||||||
|
)
|
||||||
|
return port
|
||||||
|
raise ConflictError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
||||||
|
|
||||||
|
async def create_preview_domain(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project: Project,
|
||||||
|
requested_subdomain: str,
|
||||||
|
created_by_user_id: str,
|
||||||
|
port: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
label = validate_subdomain_label(requested_subdomain)
|
||||||
|
host = build_host(label, settings.preview_root_domain)
|
||||||
|
|
||||||
|
if await self.domains.get_by_host(host) is not None:
|
||||||
|
raise ConflictError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||||
|
|
||||||
|
count = await self.domains.count_for_project(project.id)
|
||||||
|
if count >= settings.max_subdomains_per_project:
|
||||||
|
raise ConflictError(
|
||||||
|
f"هر پروژه حداکثر {settings.max_subdomains_per_project} دامنه میتواند داشته باشد."
|
||||||
|
)
|
||||||
|
|
||||||
|
target_port = port if port is not None else await self.allocate_port(project=project)
|
||||||
|
# A tenant must never be able to expose an internal platform service.
|
||||||
|
assert_not_protected_port(target_port)
|
||||||
|
|
||||||
|
result = await self.router.configure_reverse_proxy(host=host, port=target_port)
|
||||||
|
if not result.ok:
|
||||||
|
raise ValidationError(result.message or "پیکربندی دامنه ناموفق بود.")
|
||||||
|
|
||||||
|
record = await self.domains.create(
|
||||||
|
organization_id=project.organization_id,
|
||||||
|
project_id=project.id,
|
||||||
|
host=host,
|
||||||
|
port=target_port,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
record["url"] = result.url or f"https://{host}"
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def remove_preview_domain(self, *, project: Project, domain_id: str) -> None:
|
||||||
|
record = await self.domains.get(domain_id)
|
||||||
|
if record is None or record["project_id"] != project.id:
|
||||||
|
raise NotFoundError("دامنه یافت نشد.")
|
||||||
|
if record["protected"]:
|
||||||
|
raise PermissionDeniedError(
|
||||||
|
"این دامنه محافظتشده است و فقط مالک سامانه میتواند آن را تغییر دهد."
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.router.remove_preview_domain(domain_id=record["host"])
|
||||||
|
await self.domains.delete(domain_id)
|
||||||
|
|
||||||
|
async def list_preview_domains(self, *, project: Project) -> list[dict]:
|
||||||
|
records = await self.domains.list_for_project(project.id)
|
||||||
|
for record in records:
|
||||||
|
record["url"] = f"https://{record['host']}"
|
||||||
|
return records
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
"""Sandbox Gateway use cases.
|
||||||
|
|
||||||
|
The security posture is the whole point of this module:
|
||||||
|
|
||||||
|
* **No host execution path.** The only way to run anything is through
|
||||||
|
``SandboxProviderPort``. There is no fallback that shells out locally.
|
||||||
|
* **Limits come from policy, never from the caller.** A request cannot ask for a
|
||||||
|
longer timeout, more memory, or a bigger output cap than its scope allows.
|
||||||
|
* **Egress is deny-by-default.** The stored ``network_policy`` defaults to
|
||||||
|
``deny_all``; nothing here opens it implicitly.
|
||||||
|
* **Every command is metered.** CPU seconds are written to the usage ledger, so
|
||||||
|
sandbox time is billable and auditable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.application.ports import CommandResult, SandboxProviderPort, UsageRecord
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import ConflictError, NotFoundError, ProviderUnavailableError, ValidationError
|
||||||
|
from app.domain.ai_control.models import RateUnit
|
||||||
|
from app.domain.base import utcnow
|
||||||
|
from app.domain.sandbox.models import (
|
||||||
|
CommandRun,
|
||||||
|
NetworkPolicy,
|
||||||
|
PreviewUrl,
|
||||||
|
SandboxProviderKind,
|
||||||
|
SandboxResourceLimit,
|
||||||
|
SandboxSession,
|
||||||
|
SessionStatus,
|
||||||
|
Snapshot,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceStatus,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.ai_control import RateCardRepository
|
||||||
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||||
|
from app.infrastructure.repositories.projects import ProjectRepository
|
||||||
|
from app.infrastructure.repositories.sandbox import (
|
||||||
|
CommandRunRepository,
|
||||||
|
PreviewUrlRepository,
|
||||||
|
ResourceLimitRepository,
|
||||||
|
SandboxSessionRepository,
|
||||||
|
SandboxTemplateRepository,
|
||||||
|
SnapshotRepository,
|
||||||
|
WorkspaceRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Hard ceiling, independent of policy. A policy may be lower, never higher.
|
||||||
|
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||||
|
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||||
|
MAX_COMMAND_LENGTH = 4_000
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
templates: SandboxTemplateRepository,
|
||||||
|
workspaces: WorkspaceRepository,
|
||||||
|
sessions: SandboxSessionRepository,
|
||||||
|
commands: CommandRunRepository,
|
||||||
|
snapshots: SnapshotRepository,
|
||||||
|
previews: PreviewUrlRepository,
|
||||||
|
limits: ResourceLimitRepository,
|
||||||
|
rate_cards: RateCardRepository,
|
||||||
|
ledger: UsageLedgerRepository,
|
||||||
|
projects: ProjectRepository,
|
||||||
|
provider: SandboxProviderPort,
|
||||||
|
) -> None:
|
||||||
|
self.templates = templates
|
||||||
|
self.workspaces = workspaces
|
||||||
|
self.sessions = sessions
|
||||||
|
self.commands = commands
|
||||||
|
self.snapshots = snapshots
|
||||||
|
self.previews = previews
|
||||||
|
self.limits = limits
|
||||||
|
self.rate_cards = rate_cards
|
||||||
|
self.ledger = ledger
|
||||||
|
self.projects = projects
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
# -- limits --------------------------------------------------------------
|
||||||
|
|
||||||
|
async def effective_limits(
|
||||||
|
self, *, organization_id: str, project_id: str | None = None
|
||||||
|
) -> SandboxResourceLimit:
|
||||||
|
"""Resolve the strictest applicable policy.
|
||||||
|
|
||||||
|
Project scope wins over organization scope; the absolute ceiling always
|
||||||
|
wins over both.
|
||||||
|
"""
|
||||||
|
if project_id:
|
||||||
|
scoped = await self.limits.get_for_scope("project", project_id)
|
||||||
|
if scoped is not None:
|
||||||
|
return self._clamp(scoped)
|
||||||
|
|
||||||
|
org_scoped = await self.limits.get_for_scope("organization", organization_id)
|
||||||
|
if org_scoped is not None:
|
||||||
|
return self._clamp(org_scoped)
|
||||||
|
|
||||||
|
return self._clamp(
|
||||||
|
SandboxResourceLimit(
|
||||||
|
scope="platform",
|
||||||
|
scope_id=None,
|
||||||
|
timeout_seconds=settings.sandbox_command_timeout_seconds,
|
||||||
|
max_output_bytes=settings.sandbox_max_output_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clamp(limit: SandboxResourceLimit) -> SandboxResourceLimit:
|
||||||
|
limit.timeout_seconds = max(
|
||||||
|
1, min(int(limit.timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS)
|
||||||
|
)
|
||||||
|
limit.max_output_bytes = max(
|
||||||
|
1024, min(int(limit.max_output_bytes), ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||||
|
)
|
||||||
|
limit.memory_mb = max(128, int(limit.memory_mb))
|
||||||
|
limit.disk_mb = max(256, int(limit.disk_mb))
|
||||||
|
return limit
|
||||||
|
|
||||||
|
# -- templates -----------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_templates(self):
|
||||||
|
return await self.templates.list()
|
||||||
|
|
||||||
|
# -- workspaces ----------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_workspace(
|
||||||
|
self, *, organization_id: str, project_id: str, template_id: str
|
||||||
|
) -> Workspace:
|
||||||
|
template = await self.templates.get(template_id)
|
||||||
|
if template is None:
|
||||||
|
raise NotFoundError("قالب سندباکس یافت نشد.")
|
||||||
|
|
||||||
|
provider_kind = str(template.provider) or SandboxProviderKind.MOCK
|
||||||
|
try:
|
||||||
|
info = await self.provider.create_workspace(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=project_id,
|
||||||
|
template_id=template_id,
|
||||||
|
image=template.image,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - surface as a provider outage
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"ساخت محیط اجرای ایزوله ناموفق بود."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
workspace = await self.workspaces.create(
|
||||||
|
Workspace(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=project_id,
|
||||||
|
template_id=template_id,
|
||||||
|
provider=provider_kind,
|
||||||
|
external_workspace_id=info.external_id,
|
||||||
|
status=WorkspaceStatus.READY,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
async def list_workspaces(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
project_id: str,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Workspace]:
|
||||||
|
"""Workspaces for a project the caller's tenant actually owns."""
|
||||||
|
project = await self.projects.get(project_id)
|
||||||
|
# 404 rather than an empty list, so a foreign project id is
|
||||||
|
# indistinguishable from a non-existent one.
|
||||||
|
if project is None or project.organization_id != organization_id:
|
||||||
|
raise NotFoundError("پروژه یافت نشد.")
|
||||||
|
return await self.workspaces.list_for_project(
|
||||||
|
project_id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_workspace_or_404(
|
||||||
|
self, *, organization_id: str, workspace_id: str
|
||||||
|
) -> Workspace:
|
||||||
|
workspace = await self.workspaces.get_for_org(organization_id, workspace_id)
|
||||||
|
if workspace is None:
|
||||||
|
raise NotFoundError("محیط اجرا یافت نشد.")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
# -- sessions ------------------------------------------------------------
|
||||||
|
|
||||||
|
async def start_session(
|
||||||
|
self, *, organization_id: str, workspace_id: str
|
||||||
|
) -> SandboxSession:
|
||||||
|
workspace = await self.get_workspace_or_404(
|
||||||
|
organization_id=organization_id, workspace_id=workspace_id
|
||||||
|
)
|
||||||
|
if workspace.status == WorkspaceStatus.DESTROYED:
|
||||||
|
raise ConflictError("این محیط اجرا از بین رفته است.")
|
||||||
|
if not workspace.external_workspace_id:
|
||||||
|
raise ProviderUnavailableError("محیط اجرا در ارائهدهنده ثبت نشده است.")
|
||||||
|
|
||||||
|
info = await self.provider.start_session(
|
||||||
|
workspace_external_id=workspace.external_workspace_id
|
||||||
|
)
|
||||||
|
session = await self.sessions.create(
|
||||||
|
SandboxSession(
|
||||||
|
workspace_id=workspace.id,
|
||||||
|
external_session_id=info.external_id,
|
||||||
|
status=SessionStatus.RUNNING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.workspaces.set_status(workspace.id, WorkspaceStatus.READY)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def get_session_or_404(
|
||||||
|
self, *, organization_id: str, session_id: str
|
||||||
|
) -> SandboxSession:
|
||||||
|
session = await self.sessions.get_for_org(organization_id, session_id)
|
||||||
|
if session is None:
|
||||||
|
raise NotFoundError("نشست اجرا یافت نشد.")
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def stop_session(self, *, organization_id: str, session_id: str) -> SandboxSession:
|
||||||
|
session = await self.get_session_or_404(
|
||||||
|
organization_id=organization_id, session_id=session_id
|
||||||
|
)
|
||||||
|
if not session.external_session_id:
|
||||||
|
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||||
|
await self.provider.destroy(session_external_id=session.external_session_id)
|
||||||
|
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
|
||||||
|
refreshed = await self.sessions.get(session.id)
|
||||||
|
return refreshed or session
|
||||||
|
|
||||||
|
# -- commands ------------------------------------------------------------
|
||||||
|
|
||||||
|
async def run_command(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
session_id: str,
|
||||||
|
command: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> tuple[CommandRun, CommandResult]:
|
||||||
|
session = await self.get_session_or_404(
|
||||||
|
organization_id=organization_id, session_id=session_id
|
||||||
|
)
|
||||||
|
if not session.external_session_id:
|
||||||
|
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||||
|
if session.status != SessionStatus.RUNNING:
|
||||||
|
raise ConflictError("نشست در وضعیت اجرا نیست.")
|
||||||
|
|
||||||
|
cleaned = command.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValidationError("دستور نمیتواند خالی باشد.")
|
||||||
|
if len(cleaned) > MAX_COMMAND_LENGTH:
|
||||||
|
raise ValidationError("دستور بیش از حد طولانی است.")
|
||||||
|
|
||||||
|
workspace = await self.workspaces.get_for_org(
|
||||||
|
organization_id, session.workspace_id
|
||||||
|
)
|
||||||
|
limits = await self.effective_limits(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=workspace.project_id if workspace else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.provider.run_command(
|
||||||
|
session_external_id=session.external_session_id,
|
||||||
|
command=cleaned,
|
||||||
|
# Timeout and output cap are decided here, not by the caller.
|
||||||
|
timeout_seconds=limits.timeout_seconds,
|
||||||
|
max_output_bytes=limits.max_output_bytes,
|
||||||
|
env={},
|
||||||
|
)
|
||||||
|
|
||||||
|
run = await self.commands.create(
|
||||||
|
CommandRun(
|
||||||
|
session_id=session.id,
|
||||||
|
command=cleaned,
|
||||||
|
exit_code=result.exit_code,
|
||||||
|
stdout_ref=result.stdout[: limits.max_output_bytes],
|
||||||
|
stderr_ref=result.stderr[: limits.max_output_bytes],
|
||||||
|
duration_ms=result.duration_ms,
|
||||||
|
truncated=result.truncated,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.sessions.set_status(session.id, SessionStatus.RUNNING)
|
||||||
|
|
||||||
|
# CPU seconds are billed. 1 second of wall time ~= 1 CPU second at MVP.
|
||||||
|
cpu_seconds = max(0.0, result.duration_ms / 1000.0)
|
||||||
|
if cpu_seconds > 0:
|
||||||
|
card = await self.rate_cards.get_by_unit(RateUnit.SANDBOX_CPU_SECOND)
|
||||||
|
unit_price = card.customer_price if card else 0.0
|
||||||
|
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||||
|
await self.ledger.record(
|
||||||
|
usage=UsageRecord(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=workspace.project_id if workspace else None,
|
||||||
|
user_id=user_id,
|
||||||
|
unit=RateUnit.SANDBOX_CPU_SECOND,
|
||||||
|
quantity=cpu_seconds,
|
||||||
|
unit_price=unit_price,
|
||||||
|
internal_cost_estimate=cpu_seconds * internal_cost,
|
||||||
|
total_price=cpu_seconds * unit_price,
|
||||||
|
metadata_json={"session_id": session.id, "exit_code": result.exit_code},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return run, result
|
||||||
|
|
||||||
|
async def list_commands(self, *, session_id: str) -> list[CommandRun]:
|
||||||
|
return await self.commands.list_for_session(session_id)
|
||||||
|
|
||||||
|
# -- files ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def read_file(
|
||||||
|
self, *, organization_id: str, session_id: str, path: str
|
||||||
|
) -> str:
|
||||||
|
session = await self.get_session_or_404(
|
||||||
|
organization_id=organization_id, session_id=session_id
|
||||||
|
)
|
||||||
|
if not session.external_session_id:
|
||||||
|
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||||
|
return await self.provider.read_file(
|
||||||
|
session_external_id=session.external_session_id, path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def write_file(
|
||||||
|
self, *, organization_id: str, session_id: str, path: str, content: str
|
||||||
|
) -> None:
|
||||||
|
session = await self.get_session_or_404(
|
||||||
|
organization_id=organization_id, session_id=session_id
|
||||||
|
)
|
||||||
|
if not session.external_session_id:
|
||||||
|
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||||
|
await self.provider.write_file(
|
||||||
|
session_external_id=session.external_session_id, path=path, content=content
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- snapshots & previews ------------------------------------------------
|
||||||
|
|
||||||
|
async def snapshot(
|
||||||
|
self, *, organization_id: str, session_id: str, name: str
|
||||||
|
) -> Snapshot:
|
||||||
|
session = await self.get_session_or_404(
|
||||||
|
organization_id=organization_id, session_id=session_id
|
||||||
|
)
|
||||||
|
if not session.external_session_id:
|
||||||
|
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||||
|
info = await self.provider.snapshot(
|
||||||
|
session_external_id=session.external_session_id, name=name
|
||||||
|
)
|
||||||
|
return await self.snapshots.create(
|
||||||
|
Snapshot(
|
||||||
|
workspace_id=session.workspace_id, name=name,
|
||||||
|
external_snapshot_id=info.external_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_preview_url(
|
||||||
|
self, *, organization_id: str, session_id: str, port: int
|
||||||
|
) -> PreviewUrl:
|
||||||
|
session = await self.get_session_or_404(
|
||||||
|
organization_id=organization_id, session_id=session_id
|
||||||
|
)
|
||||||
|
if not session.external_session_id:
|
||||||
|
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||||
|
url = await self.provider.get_preview_url(
|
||||||
|
session_external_id=session.external_session_id, port=port
|
||||||
|
)
|
||||||
|
return await self.previews.create(
|
||||||
|
PreviewUrl(workspace_id=session.workspace_id, port=port, url=url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- policy administration ----------------------------------------------
|
||||||
|
|
||||||
|
async def set_limits(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
cpu: float,
|
||||||
|
memory_mb: int,
|
||||||
|
disk_mb: int,
|
||||||
|
timeout_seconds: int,
|
||||||
|
max_output_bytes: int,
|
||||||
|
network_policy: NetworkPolicy,
|
||||||
|
) -> SandboxResourceLimit:
|
||||||
|
if scope not in ("organization", "project", "platform"):
|
||||||
|
raise ValidationError("دامنهٔ محدودیت نامعتبر است.")
|
||||||
|
return await self.limits.upsert(
|
||||||
|
self._clamp(
|
||||||
|
SandboxResourceLimit(
|
||||||
|
scope=scope, scope_id=scope_id, cpu=cpu, memory_mb=memory_mb,
|
||||||
|
disk_mb=disk_mb, timeout_seconds=timeout_seconds,
|
||||||
|
max_output_bytes=max_output_bytes, network_policy=network_policy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def destroy_workspace(
|
||||||
|
self, *, organization_id: str, workspace_id: str
|
||||||
|
) -> Workspace:
|
||||||
|
workspace = await self.get_workspace_or_404(
|
||||||
|
organization_id=organization_id, workspace_id=workspace_id
|
||||||
|
)
|
||||||
|
for session in await self.sessions.list_for_workspace(workspace.id):
|
||||||
|
if session.external_session_id:
|
||||||
|
# Teardown is best-effort: a remote sandbox that is already gone
|
||||||
|
# must not block us from marking the local row stopped.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self.provider.destroy(
|
||||||
|
session_external_id=session.external_session_id
|
||||||
|
)
|
||||||
|
await self.sessions.set_status(
|
||||||
|
session.id, SessionStatus.STOPPED, stopped=True
|
||||||
|
)
|
||||||
|
await self.workspaces.set_status(workspace.id, WorkspaceStatus.DESTROYED)
|
||||||
|
refreshed = await self.workspaces.get_for_org(organization_id, workspace_id)
|
||||||
|
return refreshed or workspace
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now():
|
||||||
|
return utcnow()
|
||||||
|
|
||||||
|
# -- platform maintenance ------------------------------------------------
|
||||||
|
|
||||||
|
async def reap_idle_sessions(
|
||||||
|
self, *, idle_before: datetime, limit: int = 200
|
||||||
|
) -> list[SandboxSession]:
|
||||||
|
"""Stop sandbox sessions untouched since ``idle_before``.
|
||||||
|
|
||||||
|
**Deliberately not tenant-scoped.** This is the gateway's own housekeeping
|
||||||
|
and spans every organization, so it is never reachable from a
|
||||||
|
tenant-scoped route — only from the sandbox-gateway worker. Callers are
|
||||||
|
responsible for the audit trail.
|
||||||
|
|
||||||
|
A provider that has already forgotten the session must not keep it
|
||||||
|
"active" in our own record forever, so the remote teardown is
|
||||||
|
best-effort and the local row is always marked stopped.
|
||||||
|
"""
|
||||||
|
stopped: list[SandboxSession] = []
|
||||||
|
stale = await self.sessions.list_stale_active(
|
||||||
|
idle_before=idle_before, limit=limit
|
||||||
|
)
|
||||||
|
for session in stale:
|
||||||
|
if session.external_session_id:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self.provider.destroy(
|
||||||
|
session_external_id=session.external_session_id
|
||||||
|
)
|
||||||
|
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
|
||||||
|
refreshed = await self.sessions.get(session.id)
|
||||||
|
stopped.append(refreshed or session)
|
||||||
|
return stopped
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
"""AI Workforce use cases: employees, Manager Chat, tasks and the run engine.
|
||||||
|
|
||||||
|
This module is the application layer. It owns three invariants that the rest of
|
||||||
|
the system depends on:
|
||||||
|
|
||||||
|
1. **A tenant never sees model identity.** The service resolves a routing policy
|
||||||
|
to a concrete ``AiModel`` internally and passes it to the provider port. No
|
||||||
|
router may serialise that name back to a customer.
|
||||||
|
2. **Every model call writes a usage event.** Billing reads the internal ledger,
|
||||||
|
never the provider's invoice.
|
||||||
|
3. **Every run leaves a timeline.** ``run_steps`` is the observable record of
|
||||||
|
what happened, and it is what the UI renders.
|
||||||
|
|
||||||
|
The MVP "engine" is deliberately synchronous and deterministic: it drives a run
|
||||||
|
through its real state machine (``RUN_TRANSITIONS``) and records each transition
|
||||||
|
as a step. Swapping in a durable worker only changes *where* the transitions are
|
||||||
|
executed, not the transitions themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.application.ports import (
|
||||||
|
ChatMessage,
|
||||||
|
ModelProviderPort,
|
||||||
|
UsageRecord,
|
||||||
|
)
|
||||||
|
from app.core.errors import (
|
||||||
|
ConflictError,
|
||||||
|
NotFoundError,
|
||||||
|
PermissionDeniedError,
|
||||||
|
ProviderUnavailableError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from app.domain.ai_control.models import RateUnit, RoutingMode
|
||||||
|
from app.domain.workforce.models import (
|
||||||
|
TERMINAL_RUN_STATUSES,
|
||||||
|
AgentTemplate,
|
||||||
|
Artifact,
|
||||||
|
Employee,
|
||||||
|
EmployeeStatus,
|
||||||
|
EmployeeToolPermission,
|
||||||
|
Message,
|
||||||
|
MessageRole,
|
||||||
|
PromptVersion,
|
||||||
|
RoleKey,
|
||||||
|
Run,
|
||||||
|
RunStatus,
|
||||||
|
RunStep,
|
||||||
|
Task,
|
||||||
|
TaskStatus,
|
||||||
|
Thread,
|
||||||
|
ToolCall,
|
||||||
|
ToolCallStatus,
|
||||||
|
can_transition_run,
|
||||||
|
)
|
||||||
|
from app.domain.workforce.tools import (
|
||||||
|
TOOL_CATALOG,
|
||||||
|
ToolSpec,
|
||||||
|
is_known_tool,
|
||||||
|
tool_requires_approval,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.ai_control import (
|
||||||
|
AiModelRepository,
|
||||||
|
ModelCredentialRepository,
|
||||||
|
RateCardRepository,
|
||||||
|
RoutingPolicyRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||||
|
from app.infrastructure.repositories.workforce import (
|
||||||
|
AgentTemplateRepository,
|
||||||
|
ArtifactRepository,
|
||||||
|
EmployeeRepository,
|
||||||
|
EmployeeToolPermissionRepository,
|
||||||
|
MessageRepository,
|
||||||
|
PromptVersionRepository,
|
||||||
|
RunRepository,
|
||||||
|
RunStepRepository,
|
||||||
|
TaskRepository,
|
||||||
|
ThreadRepository,
|
||||||
|
ToolCallRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Fallback price when the platform owner has not configured a rate card yet.
|
||||||
|
#: Charging zero silently would hide a misconfiguration, so we use a tiny
|
||||||
|
#: non-zero placeholder and mark it in the usage metadata.
|
||||||
|
_UNPRICED = 0.0000001
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = (
|
||||||
|
"تو دستیار هوش مصنوعی سازمان هستی. پاسخها را کوتاه، دقیق و عملی بده. "
|
||||||
|
"هیچگاه نام مدل، ارائهدهنده یا جزئیات زیرساخت را فاش نکن."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkforceService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
employees: EmployeeRepository,
|
||||||
|
templates: AgentTemplateRepository,
|
||||||
|
prompts: PromptVersionRepository,
|
||||||
|
permissions: EmployeeToolPermissionRepository,
|
||||||
|
threads: ThreadRepository,
|
||||||
|
messages: MessageRepository,
|
||||||
|
tasks: TaskRepository,
|
||||||
|
runs: RunRepository,
|
||||||
|
run_steps: RunStepRepository,
|
||||||
|
tool_calls: ToolCallRepository,
|
||||||
|
artifacts: ArtifactRepository,
|
||||||
|
models: AiModelRepository,
|
||||||
|
credentials: ModelCredentialRepository,
|
||||||
|
routing_policies: RoutingPolicyRepository,
|
||||||
|
rate_cards: RateCardRepository,
|
||||||
|
ledger: UsageLedgerRepository,
|
||||||
|
provider: ModelProviderPort,
|
||||||
|
) -> None:
|
||||||
|
self.employees = employees
|
||||||
|
self.templates = templates
|
||||||
|
self.prompts = prompts
|
||||||
|
self.permissions = permissions
|
||||||
|
self.threads = threads
|
||||||
|
self.messages = messages
|
||||||
|
self.tasks = tasks
|
||||||
|
self.runs = runs
|
||||||
|
self.run_steps = run_steps
|
||||||
|
self.tool_calls = tool_calls
|
||||||
|
self.artifacts = artifacts
|
||||||
|
self.models = models
|
||||||
|
self.credentials = credentials
|
||||||
|
self.routing_policies = routing_policies
|
||||||
|
self.rate_cards = rate_cards
|
||||||
|
self.ledger = ledger
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Templates #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def list_templates(self) -> list[AgentTemplate]:
|
||||||
|
return await self.templates.list()
|
||||||
|
|
||||||
|
async def list_routing_choices(self) -> list:
|
||||||
|
"""Active routing policies, for customer-facing selection.
|
||||||
|
|
||||||
|
Callers must render only ``name``/``description`` — ``mode`` is a routing
|
||||||
|
hint that should not reach a tenant.
|
||||||
|
"""
|
||||||
|
return [p for p in await self.routing_policies.list() if p.is_active]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Employees #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def create_employee(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
created_by_user_id: str,
|
||||||
|
name: str,
|
||||||
|
role_key: RoleKey,
|
||||||
|
template_id: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
routing_policy_id: str | None = None,
|
||||||
|
) -> Employee:
|
||||||
|
template: AgentTemplate | None = None
|
||||||
|
if template_id:
|
||||||
|
template = await self.templates.get(template_id)
|
||||||
|
if template is None:
|
||||||
|
raise NotFoundError("قالب کارمند یافت نشد.")
|
||||||
|
|
||||||
|
employee = await self.employees.create(
|
||||||
|
Employee(
|
||||||
|
organization_id=organization_id,
|
||||||
|
name=name.strip(),
|
||||||
|
role_key=role_key,
|
||||||
|
template_id=template_id,
|
||||||
|
project_id=project_id,
|
||||||
|
routing_policy_id=routing_policy_id
|
||||||
|
or (template.default_routing_policy_id if template else None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed version 1 of the prompt from the template, so the employee is
|
||||||
|
# usable immediately and every later edit is a tracked revision.
|
||||||
|
if template is not None and template.default_prompt:
|
||||||
|
await self.prompts.create(
|
||||||
|
PromptVersion(
|
||||||
|
employee_id=employee.id,
|
||||||
|
version=1,
|
||||||
|
content=template.default_prompt,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if template is not None:
|
||||||
|
for tool_key in template.default_tools_json:
|
||||||
|
await self.permissions.set_permission(
|
||||||
|
employee_id=employee.id, tool_key=tool_key, allowed=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return employee
|
||||||
|
|
||||||
|
async def list_employees(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Employee]:
|
||||||
|
return await self.employees.list_for_org(organization_id, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
async def get_employee_or_404(
|
||||||
|
self, *, organization_id: str, employee_id: str
|
||||||
|
) -> Employee:
|
||||||
|
employee = await self.employees.get_for_org(organization_id, employee_id)
|
||||||
|
# 404 rather than 403: another tenant's id must be indistinguishable
|
||||||
|
# from a non-existent one.
|
||||||
|
if employee is None:
|
||||||
|
raise NotFoundError("کارمند یافت نشد.")
|
||||||
|
return employee
|
||||||
|
|
||||||
|
async def update_employee(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
employee_id: str,
|
||||||
|
name: str | None = None,
|
||||||
|
status: EmployeeStatus | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
routing_policy_id: str | None = None,
|
||||||
|
) -> Employee:
|
||||||
|
employee = await self.get_employee_or_404(
|
||||||
|
organization_id=organization_id, employee_id=employee_id
|
||||||
|
)
|
||||||
|
if name is not None:
|
||||||
|
employee.name = name.strip()
|
||||||
|
if status is not None:
|
||||||
|
employee.status = status
|
||||||
|
if project_id is not None:
|
||||||
|
employee.project_id = project_id or None
|
||||||
|
if routing_policy_id is not None:
|
||||||
|
employee.routing_policy_id = routing_policy_id or None
|
||||||
|
return await self.employees.update(employee)
|
||||||
|
|
||||||
|
async def list_prompt_versions(self, employee_id: str) -> list[PromptVersion]:
|
||||||
|
return await self.prompts.list_for_employee(employee_id)
|
||||||
|
|
||||||
|
async def add_prompt_version(
|
||||||
|
self, *, employee_id: str, content: str, created_by_user_id: str
|
||||||
|
) -> PromptVersion:
|
||||||
|
if not content.strip():
|
||||||
|
raise ValidationError("متن پرامپت نمیتواند خالی باشد.")
|
||||||
|
return await self.prompts.create(
|
||||||
|
PromptVersion(
|
||||||
|
employee_id=employee_id,
|
||||||
|
version=await self.prompts.next_version_number(employee_id),
|
||||||
|
content=content,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_tool_permissions(self, employee_id: str) -> list[EmployeeToolPermission]:
|
||||||
|
return await self.permissions.list_for_employee(employee_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Threads & messages #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def create_thread(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
created_by_user_id: str,
|
||||||
|
title: str = "",
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> Thread:
|
||||||
|
return await self.threads.create(
|
||||||
|
Thread(
|
||||||
|
organization_id=organization_id,
|
||||||
|
title=title.strip(),
|
||||||
|
project_id=project_id,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_threads(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Thread]:
|
||||||
|
return await self.threads.list_for_org(organization_id, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
async def get_thread_or_404(self, *, organization_id: str, thread_id: str) -> Thread:
|
||||||
|
thread = await self.threads.get_for_org(organization_id, thread_id)
|
||||||
|
if thread is None:
|
||||||
|
raise NotFoundError("گفتگو یافت نشد.")
|
||||||
|
return thread
|
||||||
|
|
||||||
|
async def list_messages(self, thread_id: str) -> list[Message]:
|
||||||
|
return await self.messages.list_for_thread(thread_id)
|
||||||
|
|
||||||
|
async def post_user_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
thread_id: str,
|
||||||
|
content: str,
|
||||||
|
user_id: str,
|
||||||
|
employee_id: str | None = None,
|
||||||
|
) -> tuple[Message, Message]:
|
||||||
|
"""Append the user's message, then produce the assistant reply.
|
||||||
|
|
||||||
|
Returns ``(user_message, assistant_message)`` so the caller can render
|
||||||
|
both without a second round-trip.
|
||||||
|
"""
|
||||||
|
thread = await self.get_thread_or_404(
|
||||||
|
organization_id=organization_id, thread_id=thread_id
|
||||||
|
)
|
||||||
|
if not content.strip():
|
||||||
|
raise ValidationError("متن پیام نمیتواند خالی باشد.")
|
||||||
|
|
||||||
|
user_message = await self.messages.create(
|
||||||
|
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
|
||||||
|
)
|
||||||
|
|
||||||
|
employee: Employee | None = None
|
||||||
|
if employee_id:
|
||||||
|
employee = await self.get_employee_or_404(
|
||||||
|
organization_id=organization_id, employee_id=employee_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
employee = await self._default_manager(organization_id)
|
||||||
|
|
||||||
|
history = await self.messages.list_for_thread(thread.id)
|
||||||
|
system_prompt = await self._system_prompt_for(employee)
|
||||||
|
reply = await self._complete(
|
||||||
|
organization_id=organization_id,
|
||||||
|
employee=employee,
|
||||||
|
project_id=thread.project_id,
|
||||||
|
user_id=user_id,
|
||||||
|
system=system_prompt,
|
||||||
|
messages=[
|
||||||
|
ChatMessage(role=m.role.value, content=m.content)
|
||||||
|
for m in history
|
||||||
|
if m.role in (MessageRole.USER, MessageRole.ASSISTANT)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assistant_message = await self.messages.create(
|
||||||
|
Message(
|
||||||
|
thread_id=thread.id,
|
||||||
|
role=MessageRole.ASSISTANT,
|
||||||
|
content=reply,
|
||||||
|
employee_id=employee.id if employee else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return user_message, assistant_message
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Tasks #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def create_task(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
created_by_user_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
project_id: str | None = None,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
assigned_employee_id: str | None = None,
|
||||||
|
) -> Task:
|
||||||
|
if not title.strip():
|
||||||
|
raise ValidationError("عنوان کار نمیتواند خالی باشد.")
|
||||||
|
if assigned_employee_id:
|
||||||
|
# Validates tenant ownership before we ever store the reference.
|
||||||
|
await self.get_employee_or_404(
|
||||||
|
organization_id=organization_id, employee_id=assigned_employee_id
|
||||||
|
)
|
||||||
|
return await self.tasks.create(
|
||||||
|
Task(
|
||||||
|
organization_id=organization_id,
|
||||||
|
title=title.strip(),
|
||||||
|
description=description,
|
||||||
|
project_id=project_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
assigned_employee_id=assigned_employee_id,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_tasks(
|
||||||
|
self,
|
||||||
|
organization_id: str,
|
||||||
|
*,
|
||||||
|
status: TaskStatus | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Task]:
|
||||||
|
return await self.tasks.list_for_org(
|
||||||
|
organization_id, status=status, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_task_or_404(self, *, organization_id: str, task_id: str) -> Task:
|
||||||
|
task = await self.tasks.get_for_org(organization_id, task_id)
|
||||||
|
if task is None:
|
||||||
|
raise NotFoundError("کار یافت نشد.")
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def list_task_runs(self, task_id: str) -> list[Run]:
|
||||||
|
return await self.runs.list_for_task(task_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Runs #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def start_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
task_id: str,
|
||||||
|
user_id: str,
|
||||||
|
employee_id: str | None = None,
|
||||||
|
) -> Run:
|
||||||
|
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
|
||||||
|
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
|
||||||
|
raise ConflictError("این کار قبلاً بسته شده است.")
|
||||||
|
|
||||||
|
employee: Employee | None = None
|
||||||
|
if employee_id or task.assigned_employee_id:
|
||||||
|
employee = await self.get_employee_or_404(
|
||||||
|
organization_id=organization_id,
|
||||||
|
employee_id=employee_id or task.assigned_employee_id or "",
|
||||||
|
)
|
||||||
|
if not employee.is_available:
|
||||||
|
raise ConflictError("این کارمند در حالت فعال نیست.")
|
||||||
|
else:
|
||||||
|
employee = await self._default_manager(organization_id)
|
||||||
|
|
||||||
|
run = await self.runs.create(
|
||||||
|
Run(task_id=task.id, employee_id=employee.id if employee else None)
|
||||||
|
)
|
||||||
|
await self.tasks.set_status(task.id, TaskStatus.RUNNING)
|
||||||
|
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="queued", title="کار در صف اجرا قرار گرفت.",
|
||||||
|
payload={"task_id": task.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run = await self._advance(run, RunStatus.PLANNING)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="planning", title="برنامهریزی مرحلهٔ اجرا."
|
||||||
|
)
|
||||||
|
|
||||||
|
run = await self._advance(run, RunStatus.RUNNING)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="running", title="اجرای کار آغاز شد."
|
||||||
|
)
|
||||||
|
|
||||||
|
reply = await self._complete(
|
||||||
|
organization_id=organization_id,
|
||||||
|
employee=employee,
|
||||||
|
project_id=task.project_id,
|
||||||
|
user_id=user_id,
|
||||||
|
run_id=run.id,
|
||||||
|
system=await self._system_prompt_for(employee),
|
||||||
|
messages=[
|
||||||
|
ChatMessage(role="user", content=self._task_brief(task)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="output", title="خروجی آماده شد.",
|
||||||
|
payload={"chars": len(reply)},
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = await self.artifacts.create(
|
||||||
|
Artifact(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=task.project_id,
|
||||||
|
run_id=run.id,
|
||||||
|
name=f"خروجی {task.title}"[:200],
|
||||||
|
kind="text",
|
||||||
|
content_ref=reply[:8000],
|
||||||
|
size_bytes=len(reply.encode("utf-8")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="artifact", title="سند خروجی ساخته شد.",
|
||||||
|
payload={"artifact_id": artifact.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
run = await self._finish(run, RunStatus.COMPLETED)
|
||||||
|
await self.tasks.set_status(task.id, TaskStatus.DONE)
|
||||||
|
except ProviderUnavailableError as exc:
|
||||||
|
run = await self._finish(run, RunStatus.FAILED, error=str(exc))
|
||||||
|
await self.tasks.set_status(task.id, TaskStatus.FAILED)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="error", title="اجرا ناموفق بود.",
|
||||||
|
payload={"error": str(exc)},
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - the run must always terminate
|
||||||
|
run = await self._finish(run, RunStatus.FAILED, error=str(exc))
|
||||||
|
await self.tasks.set_status(task.id, TaskStatus.FAILED)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="error", title="خطای غیرمنتظره در اجرا.",
|
||||||
|
payload={"error": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
async def get_run_or_404(
|
||||||
|
self, *, organization_id: str, run_id: str
|
||||||
|
) -> tuple[Run, list[RunStep], list[ToolCall], list[Artifact]]:
|
||||||
|
run = await self.runs.get(run_id)
|
||||||
|
if run is None:
|
||||||
|
raise NotFoundError("اجرا یافت نشد.")
|
||||||
|
task = await self.tasks.get(run.task_id)
|
||||||
|
# Re-scope through the task, which is the object that carries the tenant.
|
||||||
|
if task is None or task.organization_id != organization_id:
|
||||||
|
raise NotFoundError("اجرا یافت نشد.")
|
||||||
|
return (
|
||||||
|
run,
|
||||||
|
await self.run_steps.list_for_run(run.id),
|
||||||
|
await self.tool_calls.list_for_run(run.id),
|
||||||
|
await self.artifacts.list_for_run(run.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cancel_run(self, *, organization_id: str, run_id: str) -> Run:
|
||||||
|
run, _, _, _ = await self.get_run_or_404(
|
||||||
|
organization_id=organization_id, run_id=run_id
|
||||||
|
)
|
||||||
|
if run.status in TERMINAL_RUN_STATUSES:
|
||||||
|
raise ConflictError("این اجرا در وضعیت پایانی است و لغو نمیشود.")
|
||||||
|
if not can_transition_run(run.status, RunStatus.CANCELLED):
|
||||||
|
raise ConflictError("از وضعیت فعلی امکان لغو وجود ندارد.")
|
||||||
|
|
||||||
|
run = await self._finish(run, RunStatus.CANCELLED)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id, kind="cancelled", title="اجرا توسط کاربر لغو شد."
|
||||||
|
)
|
||||||
|
task = await self.tasks.get(run.task_id)
|
||||||
|
if task is not None and task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
|
||||||
|
await self.tasks.set_status(task.id, TaskStatus.CANCELLED)
|
||||||
|
return run
|
||||||
|
|
||||||
|
async def list_pending_approvals(self, organization_id: str) -> list[ToolCall]:
|
||||||
|
return await self.tool_calls.list_pending_approvals_for_org(organization_id)
|
||||||
|
|
||||||
|
async def list_artifacts(self, organization_id: str) -> list[Artifact]:
|
||||||
|
return await self.artifacts.list_for_org(organization_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Tools & approvals #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tool_catalog() -> tuple[ToolSpec, ...]:
|
||||||
|
return TOOL_CATALOG
|
||||||
|
|
||||||
|
async def set_tool_permission(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
employee_id: str,
|
||||||
|
tool_key: str,
|
||||||
|
allowed: bool,
|
||||||
|
) -> EmployeeToolPermission:
|
||||||
|
"""Grant or revoke one tool for one employee.
|
||||||
|
|
||||||
|
An unknown tool key is rejected rather than stored: a permission row for
|
||||||
|
a tool that does not exist is a silent no-op that hides a typo.
|
||||||
|
"""
|
||||||
|
await self.get_employee_or_404(
|
||||||
|
organization_id=organization_id, employee_id=employee_id
|
||||||
|
)
|
||||||
|
if not is_known_tool(tool_key):
|
||||||
|
raise ValidationError(f"ابزار ناشناخته: {tool_key}")
|
||||||
|
return await self.permissions.set_permission(
|
||||||
|
employee_id=employee_id, tool_key=tool_key, allowed=allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
async def request_tool_call(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
run_id: str,
|
||||||
|
employee_id: str | None,
|
||||||
|
tool_key: str,
|
||||||
|
arguments: dict | None = None,
|
||||||
|
) -> ToolCall:
|
||||||
|
"""Record an intended tool call, gating it on permission and approval.
|
||||||
|
|
||||||
|
The call is persisted in every case so the audit trail shows what was
|
||||||
|
attempted, not only what succeeded.
|
||||||
|
"""
|
||||||
|
run, _, _, _ = await self.get_run_or_404(
|
||||||
|
organization_id=organization_id, run_id=run_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_known_tool(tool_key):
|
||||||
|
raise ValidationError(f"ابزار ناشناخته: {tool_key}")
|
||||||
|
|
||||||
|
# Deny by default. An employee with no permission row cannot call a tool.
|
||||||
|
if employee_id is None or not await self.permissions.is_allowed(
|
||||||
|
employee_id, tool_key
|
||||||
|
):
|
||||||
|
raise PermissionDeniedError(
|
||||||
|
f"کارمند مجاز به استفاده از «{tool_key}» نیست."
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_approval = tool_requires_approval(tool_key)
|
||||||
|
call = await self.tool_calls.create(
|
||||||
|
ToolCall(
|
||||||
|
run_id=run.id,
|
||||||
|
tool_key=tool_key,
|
||||||
|
arguments_json=dict(arguments or {}),
|
||||||
|
status=(
|
||||||
|
ToolCallStatus.AWAITING_APPROVAL
|
||||||
|
if needs_approval
|
||||||
|
else ToolCallStatus.PENDING
|
||||||
|
),
|
||||||
|
requires_approval=needs_approval,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=run.id,
|
||||||
|
kind="tool_call",
|
||||||
|
title=(
|
||||||
|
f"درخواست تأیید برای «{tool_key}»"
|
||||||
|
if needs_approval
|
||||||
|
else f"فراخوانی «{tool_key}»"
|
||||||
|
),
|
||||||
|
payload={"tool_call_id": call.id, "requires_approval": needs_approval},
|
||||||
|
)
|
||||||
|
return call
|
||||||
|
|
||||||
|
async def decide_approval(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
tool_call_id: str,
|
||||||
|
approve: bool,
|
||||||
|
decided_by_user_id: str,
|
||||||
|
note: str = "",
|
||||||
|
) -> ToolCall:
|
||||||
|
"""Approve or reject a pending tool call. Only a human may call this."""
|
||||||
|
call = await self.tool_calls.get_for_org(organization_id, tool_call_id)
|
||||||
|
if call is None:
|
||||||
|
raise NotFoundError("درخواست تأیید یافت نشد.")
|
||||||
|
if call.status != ToolCallStatus.AWAITING_APPROVAL:
|
||||||
|
raise ConflictError("این درخواست در وضعیت انتظار تأیید نیست.")
|
||||||
|
|
||||||
|
result = dict(call.result_json)
|
||||||
|
if note:
|
||||||
|
result["note"] = note
|
||||||
|
|
||||||
|
updated = await self.tool_calls.set_status(
|
||||||
|
call.id,
|
||||||
|
ToolCallStatus.APPROVED if approve else ToolCallStatus.REJECTED,
|
||||||
|
result=result,
|
||||||
|
approved_by_user_id=decided_by_user_id,
|
||||||
|
)
|
||||||
|
await self.run_steps.append(
|
||||||
|
run_id=call.run_id,
|
||||||
|
kind="approval",
|
||||||
|
title=("درخواست تأیید شد." if approve else "درخواست رد شد."),
|
||||||
|
payload={"tool_call_id": call.id, "tool_key": call.tool_key},
|
||||||
|
)
|
||||||
|
return updated or call
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Internals #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
async def _default_manager(self, organization_id: str) -> Employee | None:
|
||||||
|
"""The employee that answers when the caller did not pick one."""
|
||||||
|
for employee in await self.employees.list_for_org(organization_id):
|
||||||
|
if employee.role_key == RoleKey.MANAGER and employee.is_available:
|
||||||
|
return employee
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _system_prompt_for(self, employee: Employee | None) -> str:
|
||||||
|
if employee is None:
|
||||||
|
return _SYSTEM_PROMPT
|
||||||
|
latest = await self.prompts.latest(employee.id)
|
||||||
|
if latest is None:
|
||||||
|
return _SYSTEM_PROMPT
|
||||||
|
return f"{_SYSTEM_PROMPT}\n\n{latest.content}"
|
||||||
|
|
||||||
|
async def _resolve_model(self, employee: Employee | None):
|
||||||
|
"""Map the employee's routing policy to a concrete model. Never exposed."""
|
||||||
|
mode = RoutingMode.BALANCED
|
||||||
|
policy_id = employee.routing_policy_id if employee else None
|
||||||
|
policy = (
|
||||||
|
await self.routing_policies.get(policy_id)
|
||||||
|
if policy_id
|
||||||
|
else await self.routing_policies.get_default()
|
||||||
|
)
|
||||||
|
if policy is not None:
|
||||||
|
mode = policy.mode
|
||||||
|
|
||||||
|
model = await self.models.pick_for_mode(mode)
|
||||||
|
if model is None:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"هیچ مدل فعالی برای مسیریابی پیکربندی نشده است."
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
|
||||||
|
async def _complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
employee: Employee | None,
|
||||||
|
project_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
system: str,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Run one completion and record its usage. The single model entry point."""
|
||||||
|
model = await self._resolve_model(employee)
|
||||||
|
credential = await self.credentials.get_active_for_provider(model.provider_id)
|
||||||
|
|
||||||
|
response = await self.provider.complete(
|
||||||
|
model=model, credential=credential, messages=messages, system=system
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._record_usage(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=user_id,
|
||||||
|
employee_id=employee.id if employee else None,
|
||||||
|
run_id=run_id,
|
||||||
|
unit=RateUnit.MODEL_INPUT_TOKENS,
|
||||||
|
quantity=float(response.input_tokens),
|
||||||
|
)
|
||||||
|
await self._record_usage(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=user_id,
|
||||||
|
employee_id=employee.id if employee else None,
|
||||||
|
run_id=run_id,
|
||||||
|
unit=RateUnit.MODEL_OUTPUT_TOKENS,
|
||||||
|
quantity=float(response.output_tokens),
|
||||||
|
)
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
async def _record_usage(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
unit: RateUnit,
|
||||||
|
quantity: float,
|
||||||
|
project_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
employee_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
card = await self.rate_cards.get_by_unit(unit)
|
||||||
|
unit_price = card.customer_price if card else _UNPRICED
|
||||||
|
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||||
|
await self.ledger.record(
|
||||||
|
usage=UsageRecord(
|
||||||
|
organization_id=organization_id,
|
||||||
|
project_id=project_id,
|
||||||
|
user_id=user_id,
|
||||||
|
employee_id=employee_id,
|
||||||
|
run_id=run_id,
|
||||||
|
unit=unit,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_price=unit_price,
|
||||||
|
internal_cost_estimate=quantity * internal_cost,
|
||||||
|
total_price=quantity * unit_price,
|
||||||
|
metadata_json={"priced": card is not None},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _advance(self, run: Run, target: RunStatus) -> Run:
|
||||||
|
if not can_transition_run(run.status, target):
|
||||||
|
raise ConflictError(f"گذار نامعتبر از {run.status} به {target}.")
|
||||||
|
return await self.runs.set_status(run.id, target) or run
|
||||||
|
|
||||||
|
async def _finish(self, run: Run, target: RunStatus, *, error: str | None = None) -> Run:
|
||||||
|
from app.domain.base import utcnow
|
||||||
|
|
||||||
|
if not can_transition_run(run.status, target):
|
||||||
|
# Already terminal — return the current row untouched.
|
||||||
|
return await self.runs.get(run.id) or run
|
||||||
|
updated = await self.runs.set_status(
|
||||||
|
run.id, target, error=error, finished_at=utcnow()
|
||||||
|
)
|
||||||
|
return updated or run
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _task_brief(task: Task) -> str:
|
||||||
|
parts = [f"عنوان کار: {task.title}"]
|
||||||
|
if task.description:
|
||||||
|
parts.append(f"توضیحات: {task.description}")
|
||||||
|
return "\n".join(parts)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cross-cutting concerns: configuration, database, security, crypto, errors."""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""Scrub secrets out of anything before it is persisted.
|
||||||
|
|
||||||
|
This module exists because of one hard rule: **an error log that stores a
|
||||||
|
password is worse than no error log at all.** Error records are written from
|
||||||
|
inside exception handlers, where the values at hand are exactly the values most
|
||||||
|
likely to carry credentials — request bodies, ``Authorization`` headers, DSNs,
|
||||||
|
provider API keys, decrypted git tokens. Anything that reaches the database must
|
||||||
|
pass through here first.
|
||||||
|
|
||||||
|
Two independent layers, because either one alone leaks:
|
||||||
|
|
||||||
|
1. **Structural** — a key that *means* a secret (``password``, ``api_key``,
|
||||||
|
``refresh_token``, …) has its whole value replaced, whatever the value looks
|
||||||
|
like. This catches secrets in shapes no pattern would recognise.
|
||||||
|
2. **Lexical** — value patterns that are recognisable regardless of their key:
|
||||||
|
PEM blocks, JWTs, ``scheme://user:pass@host`` URLs, ``Bearer`` headers,
|
||||||
|
``sk-…``/``ghp_…``/``AKIA…`` provider keys, and ``password=…`` fragments
|
||||||
|
embedded in a larger string.
|
||||||
|
|
||||||
|
On top of both sits a third net: the **configured secret values themselves**
|
||||||
|
(``JWT_SECRET``, ``ENCRYPTION_KEY``, every ``*_API_KEY``/``*_TOKEN``) are
|
||||||
|
replaced literally wherever they appear. A secret that is only ever compared by
|
||||||
|
pattern will eventually be logged verbatim; a secret that is matched by value
|
||||||
|
cannot be.
|
||||||
|
|
||||||
|
Deliberately dependency-free and framework-free: the recorder, the HTTP layer
|
||||||
|
and the workers all import it, and it must never fail — a redactor that raises
|
||||||
|
inside an exception handler turns a 500 into a hang.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
#: What replaces a scrubbed value. Deliberately obvious in a log so a reader can
|
||||||
|
#: tell "we removed this" from "this was empty".
|
||||||
|
REDACTED = "[REDACTED]"
|
||||||
|
|
||||||
|
#: Longest string we will hand to the regex engine. Error payloads can contain
|
||||||
|
#: multi-megabyte blobs; redaction cost must stay bounded and predictable.
|
||||||
|
MAX_INPUT_CHARS = 64_000
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layer 1 — structural: keys that mean "secret"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Exact key names (after normalisation) whose value is never safe to store.
|
||||||
|
_SENSITIVE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"pwd",
|
||||||
|
"passphrase",
|
||||||
|
"secret",
|
||||||
|
"client_secret",
|
||||||
|
"token",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"authorization",
|
||||||
|
"auth",
|
||||||
|
"credentials",
|
||||||
|
"credential",
|
||||||
|
"private_key",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"id_token",
|
||||||
|
"session",
|
||||||
|
"session_id",
|
||||||
|
"session_token",
|
||||||
|
"cookie",
|
||||||
|
"set_cookie",
|
||||||
|
"jwt",
|
||||||
|
"bearer",
|
||||||
|
"encryption_key",
|
||||||
|
"jwt_secret",
|
||||||
|
"signing_key",
|
||||||
|
"webhook_secret",
|
||||||
|
"signature",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Suffixes that make a key sensitive however it is prefixed: ``db_password``,
|
||||||
|
#: ``gitea_admin_token``, ``stripe_secret_key``, ``openai_api_key``.
|
||||||
|
#:
|
||||||
|
#: Suffix — not substring — matching on purpose. ``token_count`` and
|
||||||
|
#: ``input_tokens`` are *usage* fields, and redacting them would silently
|
||||||
|
#: destroy the billing data these same records are meant to help debug.
|
||||||
|
_SENSITIVE_SUFFIXES = (
|
||||||
|
"_password",
|
||||||
|
"_passwd",
|
||||||
|
"_secret",
|
||||||
|
"_secret_key",
|
||||||
|
"_token",
|
||||||
|
"_api_key",
|
||||||
|
"_apikey",
|
||||||
|
"_private_key",
|
||||||
|
"_credential",
|
||||||
|
"_credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Keys that look sensitive by suffix rule but are known-safe counters.
|
||||||
|
_SAFE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"token_count",
|
||||||
|
"token_limit",
|
||||||
|
"tokens",
|
||||||
|
"input_tokens",
|
||||||
|
"output_tokens",
|
||||||
|
"total_tokens",
|
||||||
|
"prompt_tokens",
|
||||||
|
"completion_tokens",
|
||||||
|
"max_tokens",
|
||||||
|
"cached_tokens",
|
||||||
|
"secret_count",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_NORMALISE = re.compile(r"[^a-z0-9]+")
|
||||||
|
#: Splits camelCase so ``refreshToken`` and ``apiKey`` normalise the same way as
|
||||||
|
#: their snake_case spellings, instead of collapsing to one unguessable word.
|
||||||
|
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_key(key: str) -> str:
|
||||||
|
"""``X-API-Key`` / ``apiKey`` / ``api-key`` all become ``api_key``."""
|
||||||
|
spaced = _CAMEL_BOUNDARY.sub("_", key.strip())
|
||||||
|
return _NORMALISE.sub("_", spaced.lower()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def is_sensitive_key(key: str) -> bool:
|
||||||
|
"""True when a value stored under ``key`` must be replaced wholesale."""
|
||||||
|
normalised = _normalise_key(key)
|
||||||
|
if not normalised or normalised in _SAFE_KEYS:
|
||||||
|
return False
|
||||||
|
if normalised in _SENSITIVE_KEYS:
|
||||||
|
return True
|
||||||
|
return normalised.endswith(_SENSITIVE_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layer 2 — lexical: values that are secrets whatever their key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||||
|
# PEM / OpenSSH private keys, possibly multi-line.
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
||||||
|
re.DOTALL,
|
||||||
|
),
|
||||||
|
"[REDACTED_PRIVATE_KEY]",
|
||||||
|
),
|
||||||
|
# scheme://user:password@host — DSNs and git remotes. Keeps the scheme and
|
||||||
|
# the host so the record stays useful; drops the userinfo.
|
||||||
|
(
|
||||||
|
re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/\s:@]+:[^/\s@]+@"),
|
||||||
|
r"\g<scheme>[REDACTED]@",
|
||||||
|
),
|
||||||
|
# "Authorization: Bearer eyJ…" and friends. Keeps the scheme.
|
||||||
|
(
|
||||||
|
re.compile(r"(?i)\b(bearer|basic|token|apikey)\s+[A-Za-z0-9\-._~+/=]{8,}"),
|
||||||
|
r"\1 [REDACTED]",
|
||||||
|
),
|
||||||
|
# JSON Web Tokens: three base64url segments, always starting "eyJ".
|
||||||
|
(
|
||||||
|
re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b"),
|
||||||
|
"[REDACTED_JWT]",
|
||||||
|
),
|
||||||
|
# Provider key shapes worth naming individually.
|
||||||
|
(re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b"), "[REDACTED_KEY]"),
|
||||||
|
(re.compile(r"\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b"), "[REDACTED_KEY]"),
|
||||||
|
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_KEY]"),
|
||||||
|
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_KEY]"),
|
||||||
|
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "[REDACTED_KEY]"),
|
||||||
|
(re.compile(r"\bya29\.[0-9A-Za-z_\-]+\b"), "[REDACTED_KEY]"),
|
||||||
|
# "password=hunter2", '"api_key": "…"', "secret: abc" inside a longer text.
|
||||||
|
# The value is a quoted run, a scheme-prefixed token ("Bearer eyJ…"), or a
|
||||||
|
# bare run up to the next delimiter.
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b(password|passwd|pwd|secret|token|api[_\-]?key|client[_\-]?secret"
|
||||||
|
r"|access[_\-]?token|refresh[_\-]?token|authorization|private[_\-]?key)"
|
||||||
|
r"\b(\s*[:=]\s*)"
|
||||||
|
r"(\"[^\"]{0,512}\""
|
||||||
|
r"|'[^']{0,512}'"
|
||||||
|
r"|(?:bearer|basic|token|apikey)\s+\S{1,512}"
|
||||||
|
r"|[^\s,;&)\]}\"']{1,512})"
|
||||||
|
),
|
||||||
|
r"\1\2[REDACTED]",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_secret_values() -> tuple[str, ...]:
|
||||||
|
"""Literal secrets from settings, longest first.
|
||||||
|
|
||||||
|
Read lazily on every call rather than cached at import: the test suite
|
||||||
|
rewrites ``settings.jwt_secret`` in a fixture, and a stale cache would keep
|
||||||
|
the old value while the new one leaked.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
candidates = (
|
||||||
|
settings.jwt_secret,
|
||||||
|
settings.encryption_key,
|
||||||
|
settings.openai_compatible_api_key,
|
||||||
|
settings.gitea_admin_token,
|
||||||
|
settings.daytona_api_key,
|
||||||
|
settings.e2b_api_key,
|
||||||
|
settings.stripe_secret_key,
|
||||||
|
settings.stripe_webhook_secret,
|
||||||
|
)
|
||||||
|
# Short strings and documented placeholders are not secrets. Replacing
|
||||||
|
# "change-me" would garble messages for no security gain.
|
||||||
|
placeholders = {"change-me", "changeme", "secret", "test", "dev", "password", "none"}
|
||||||
|
usable = {
|
||||||
|
value
|
||||||
|
for value in candidates
|
||||||
|
if isinstance(value, str) and len(value) >= 8 and value.lower() not in placeholders
|
||||||
|
}
|
||||||
|
return tuple(sorted(usable, key=len, reverse=True))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def redact_text(value: str) -> str:
|
||||||
|
"""Scrub secrets from a free-form string. Never raises."""
|
||||||
|
try:
|
||||||
|
text = value if len(value) <= MAX_INPUT_CHARS else value[:MAX_INPUT_CHARS] + "…[truncated]"
|
||||||
|
for secret in _configured_secret_values():
|
||||||
|
if secret in text:
|
||||||
|
text = text.replace(secret, REDACTED)
|
||||||
|
for pattern, replacement in _PATTERNS:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
return text
|
||||||
|
except Exception: # pragma: no cover - redaction must never break a handler
|
||||||
|
# If a pattern ever explodes, drop the text rather than the request.
|
||||||
|
return REDACTED
|
||||||
|
|
||||||
|
|
||||||
|
def redact(value: Any) -> Any:
|
||||||
|
"""Recursively scrub an arbitrary JSON-ish value. Never raises."""
|
||||||
|
try:
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return redact_text(value)
|
||||||
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||||
|
return f"<{len(bytes(value))} bytes>"
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return redact_mapping(value)
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [redact(item) for item in value]
|
||||||
|
return redact_text(str(value))
|
||||||
|
except Exception: # pragma: no cover - see redact_text
|
||||||
|
return REDACTED
|
||||||
|
|
||||||
|
|
||||||
|
def redact_mapping(data: Mapping[Any, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Scrub a mapping, replacing any sensitive key's value outright."""
|
||||||
|
if not data:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, item in data.items():
|
||||||
|
name = str(key)
|
||||||
|
result[name] = REDACTED if is_sensitive_key(name) else redact(item)
|
||||||
|
return result
|
||||||
|
except Exception: # pragma: no cover - see redact_text
|
||||||
|
return {"_redaction_failed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def redact_headers(headers: Mapping[Any, Any] | None) -> dict[str, str]:
|
||||||
|
"""Scrub HTTP headers, keeping the shape of the request for debugging.
|
||||||
|
|
||||||
|
Header names are normalised to lower-case; ``authorization``, ``cookie`` and
|
||||||
|
``x-api-key`` fall out as sensitive through the same key rules as JSON.
|
||||||
|
"""
|
||||||
|
if not headers:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for key, value in headers.items():
|
||||||
|
name = str(key).lower()
|
||||||
|
result[name] = REDACTED if is_sensitive_key(name) else str(redact(value))
|
||||||
|
return result
|
||||||
|
except Exception: # pragma: no cover - see redact_text
|
||||||
|
return {}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Tool catalog for AI employees.
|
||||||
|
|
||||||
|
Three rules are encoded here as data rather than as scattered ``if`` statements:
|
||||||
|
|
||||||
|
1. Every tool has a stable key and an explicit approval requirement.
|
||||||
|
2. Dangerous tools require human approval **before** they run — the flag lives
|
||||||
|
with the tool, so a new call site cannot forget it.
|
||||||
|
3. Nothing is allowed by default; an employee needs an explicit permission row.
|
||||||
|
|
||||||
|
This module is pure: no I/O, no framework, no database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class ToolKey:
|
||||||
|
"""Stable tool identifiers. These appear in audit logs and permissions."""
|
||||||
|
|
||||||
|
GIT_ENSURE_REPO = "git.ensure_repo"
|
||||||
|
GIT_CREATE_BRANCH = "git.create_branch"
|
||||||
|
GIT_OPEN_PR = "git.open_pr"
|
||||||
|
GIT_MERGE_PR = "git.merge_pr"
|
||||||
|
GIT_DELETE_REPO = "git.delete_repo"
|
||||||
|
DOMAIN_CREATE_PREVIEW = "domain.create_preview"
|
||||||
|
DOMAIN_DELETE_PREVIEW = "domain.delete_preview"
|
||||||
|
SANDBOX_RUN_COMMAND = "sandbox.run_command"
|
||||||
|
SANDBOX_READ_FILE = "sandbox.read_file"
|
||||||
|
SANDBOX_WRITE_FILE = "sandbox.write_file"
|
||||||
|
SANDBOX_DELETE_FILE = "sandbox.delete_file"
|
||||||
|
SANDBOX_DESTROY = "sandbox.destroy"
|
||||||
|
KNOWLEDGE_SEARCH = "knowledge.search"
|
||||||
|
ARTIFACT_CREATE = "artifact.create"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ToolSpec:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
#: Human approval is required before this tool may execute.
|
||||||
|
requires_approval: bool = False
|
||||||
|
#: Irreversible or destructive: never auto-approved, always audited.
|
||||||
|
destructive: bool = False
|
||||||
|
#: Tools that touch the outside world are the ones worth approving.
|
||||||
|
category: str = "general"
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_CATALOG: tuple[ToolSpec, ...] = (
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.GIT_ENSURE_REPO,
|
||||||
|
label="ساخت مخزن",
|
||||||
|
description="ساخت مخزن خصوصی Git برای پروژه.",
|
||||||
|
category="git",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.GIT_CREATE_BRANCH,
|
||||||
|
label="ساخت شاخه",
|
||||||
|
description="ساخت یک شاخهٔ جدید از شاخهٔ مشخص.",
|
||||||
|
category="git",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.GIT_OPEN_PR,
|
||||||
|
label="باز کردن درخواست ادغام",
|
||||||
|
description="ثبت یک PR از شاخهٔ کاری به شاخهٔ پایه.",
|
||||||
|
category="git",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.GIT_MERGE_PR,
|
||||||
|
label="ادغام درخواست ادغام",
|
||||||
|
description="ادغام PR در شاخهٔ پایه. بازگشتناپذیر.",
|
||||||
|
requires_approval=True,
|
||||||
|
destructive=True,
|
||||||
|
category="git",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.GIT_DELETE_REPO,
|
||||||
|
label="حذف مخزن",
|
||||||
|
description="حذف کامل مخزن پروژه. بازگشتناپذیر.",
|
||||||
|
requires_approval=True,
|
||||||
|
destructive=True,
|
||||||
|
category="git",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.DOMAIN_CREATE_PREVIEW,
|
||||||
|
label="ساخت دامنهٔ تست",
|
||||||
|
description="انتشار موقت پروژه روی یک زیردامنه.",
|
||||||
|
category="domain",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.DOMAIN_DELETE_PREVIEW,
|
||||||
|
label="حذف دامنهٔ تست",
|
||||||
|
description="برداشتن دامنهٔ تست از دسترس.",
|
||||||
|
requires_approval=True,
|
||||||
|
category="domain",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.SANDBOX_RUN_COMMAND,
|
||||||
|
label="اجرای دستور در سندباکس",
|
||||||
|
description="اجرای یک دستور داخل محیط ایزوله با محدودیت زمان و خروجی.",
|
||||||
|
category="sandbox",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.SANDBOX_READ_FILE,
|
||||||
|
label="خواندن فایل",
|
||||||
|
description="خواندن محتوای یک فایل در سندباکس.",
|
||||||
|
category="sandbox",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.SANDBOX_WRITE_FILE,
|
||||||
|
label="نوشتن فایل",
|
||||||
|
description="نوشتن یا بازنویسی یک فایل در سندباکس.",
|
||||||
|
category="sandbox",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.SANDBOX_DELETE_FILE,
|
||||||
|
label="حذف فایل",
|
||||||
|
description="حذف یک فایل از سندباکس. بازگشتناپذیر.",
|
||||||
|
requires_approval=True,
|
||||||
|
destructive=True,
|
||||||
|
category="sandbox",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.SANDBOX_DESTROY,
|
||||||
|
label="نابودی سندباکس",
|
||||||
|
description="پایان دادن به نشست سندباکس و آزادسازی منابع.",
|
||||||
|
requires_approval=True,
|
||||||
|
destructive=True,
|
||||||
|
category="sandbox",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.KNOWLEDGE_SEARCH,
|
||||||
|
label="جستوجو در دانش",
|
||||||
|
description="جستوجو در پایگاه دانش سازمان.",
|
||||||
|
category="knowledge",
|
||||||
|
),
|
||||||
|
ToolSpec(
|
||||||
|
key=ToolKey.ARTIFACT_CREATE,
|
||||||
|
label="ساخت خروجی",
|
||||||
|
description="ثبت یک سند یا خروجی قابل مشاهده برای کاربر.",
|
||||||
|
category="artifact",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_BY_KEY: dict[str, ToolSpec] = {spec.key: spec for spec in TOOL_CATALOG}
|
||||||
|
|
||||||
|
#: Templates that reference an unknown tool key would silently grant nothing;
|
||||||
|
#: this set lets a test assert the seed data and the catalog agree.
|
||||||
|
KNOWN_TOOL_KEYS: frozenset[str] = frozenset(_BY_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_spec(key: str) -> ToolSpec | None:
|
||||||
|
return _BY_KEY.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_tool(key: str) -> bool:
|
||||||
|
return key in _BY_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def tool_requires_approval(key: str) -> bool:
|
||||||
|
"""Unknown tools are treated as requiring approval, never as safe."""
|
||||||
|
spec = _BY_KEY.get(key)
|
||||||
|
return True if spec is None else spec.requires_approval
|
||||||
|
|
||||||
|
|
||||||
|
def is_destructive(key: str) -> bool:
|
||||||
|
spec = _BY_KEY.get(key)
|
||||||
|
return False if spec is None else spec.destructive
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Caddy adapter for :class:`DomainRouterPort`.
|
||||||
|
|
||||||
|
Publishes tenant preview domains by maintaining a *managed snippet file* that the
|
||||||
|
main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited
|
||||||
|
by this adapter.
|
||||||
|
|
||||||
|
Why a snippet instead of editing the Caddyfile
|
||||||
|
---------------------------------------------
|
||||||
|
Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not
|
||||||
|
own; a mistake there takes down every site on the host. A separate file that
|
||||||
|
Caddy imports by glob keeps the blast radius to tenant preview routes only.
|
||||||
|
|
||||||
|
Setup (one line, once)
|
||||||
|
----------------------
|
||||||
|
Add this to the Caddyfile and reload Caddy::
|
||||||
|
|
||||||
|
import /etc/caddy/agy-preview/*.caddy
|
||||||
|
|
||||||
|
The adapter refuses to run if that import is missing, because silently writing a
|
||||||
|
file that Caddy never reads would report success for a domain that does not
|
||||||
|
resolve.
|
||||||
|
|
||||||
|
Security rules enforced here, not only upstream
|
||||||
|
-----------------------------------------------
|
||||||
|
* the host must be a single label under ``PREVIEW_ROOT_DOMAIN``
|
||||||
|
(:func:`validate_managed_host`) — this is what stops a crafted host from
|
||||||
|
closing the site block and injecting directives
|
||||||
|
* the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`)
|
||||||
|
— SSRF prevention
|
||||||
|
* protected platform ports are refused (:func:`assert_not_protected_port`)
|
||||||
|
* the rendered config is validated *before* it is loaded, and every failed
|
||||||
|
change is rolled back to the previous file
|
||||||
|
|
||||||
|
The ``caddy`` binary runs on the platform host. That is a different trust domain
|
||||||
|
from tenant code: tenant commands only ever run inside a sandbox adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.application.ports import PreviewDomainInfo, ReverseProxyResult
|
||||||
|
from app.core.errors import ProviderUnavailableError
|
||||||
|
from app.domain.platform.rules import (
|
||||||
|
RuleViolation,
|
||||||
|
assert_not_protected_port,
|
||||||
|
assert_port_allocatable,
|
||||||
|
build_host,
|
||||||
|
validate_managed_host,
|
||||||
|
validate_proxy_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BEGIN = "# >>> agy-managed: {host}"
|
||||||
|
_END = "# <<< agy-managed: {host}"
|
||||||
|
_BLOCK_RE = re.compile(
|
||||||
|
r"# >>> agy-managed: (?P<host>\S+)\n(?P<body>.*?)# <<< agy-managed: (?P=host)\n",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
_VALIDATE_TIMEOUT_SECONDS = 15
|
||||||
|
_RELOAD_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
class CaddyDomainRouterAdapter:
|
||||||
|
"""Reverse-proxy routes managed through a Caddy snippet file."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
caddyfile_path: str,
|
||||||
|
root_domain: str,
|
||||||
|
port_start: int = 8080,
|
||||||
|
port_end: int = 8999,
|
||||||
|
snippet_dir: str | None = None,
|
||||||
|
caddy_binary: str = "caddy",
|
||||||
|
) -> None:
|
||||||
|
self.caddyfile_path = Path(caddyfile_path)
|
||||||
|
self.root_domain = root_domain
|
||||||
|
self.port_start = port_start
|
||||||
|
self.port_end = port_end
|
||||||
|
self.caddy_binary = caddy_binary
|
||||||
|
|
||||||
|
# Default to a sibling directory of the Caddyfile so the import glob is
|
||||||
|
# a short, predictable path.
|
||||||
|
self.snippet_dir = (
|
||||||
|
Path(snippet_dir)
|
||||||
|
if snippet_dir
|
||||||
|
else self.caddyfile_path.parent / "agy-preview"
|
||||||
|
)
|
||||||
|
self.snippet_path = self.snippet_dir / "preview.caddy"
|
||||||
|
|
||||||
|
# -- port allocation -----------------------------------------------------
|
||||||
|
|
||||||
|
async def allocate_port(
|
||||||
|
self, *, organization_id: str, project_id: str, service_name: str
|
||||||
|
) -> int:
|
||||||
|
"""Pick the lowest free port that is not already routed.
|
||||||
|
|
||||||
|
The database is the authoritative port ledger
|
||||||
|
(``PreviewDomainRepository``); this only avoids handing out a port that a
|
||||||
|
live route already occupies.
|
||||||
|
"""
|
||||||
|
in_use = {entry.port for entry in self._parse(self._read_snippet())}
|
||||||
|
for port in range(self.port_start, self.port_end + 1):
|
||||||
|
if port in in_use:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||||
|
except RuleViolation:
|
||||||
|
continue
|
||||||
|
return port
|
||||||
|
raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
||||||
|
|
||||||
|
async def claim_preview_domain(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
project_id: str,
|
||||||
|
requested_subdomain: str,
|
||||||
|
port: int,
|
||||||
|
) -> PreviewDomainInfo:
|
||||||
|
host = build_host(requested_subdomain, self.root_domain)
|
||||||
|
result = await self.configure_reverse_proxy(host=host, port=port)
|
||||||
|
if not result.ok:
|
||||||
|
raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.")
|
||||||
|
return PreviewDomainInfo(host=host, port=port, url=result.url, status="active")
|
||||||
|
|
||||||
|
# -- routing -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||||
|
"""Create or replace the route for ``host`` and reload Caddy."""
|
||||||
|
try:
|
||||||
|
clean_host = validate_managed_host(host, self.root_domain)
|
||||||
|
# Raises unless the target is loopback with a numeric port.
|
||||||
|
validate_proxy_target(f"localhost:{port}")
|
||||||
|
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||||
|
assert_not_protected_port(port)
|
||||||
|
except RuleViolation as exc:
|
||||||
|
# A rejected rule is a caller error, not an outage.
|
||||||
|
return ReverseProxyResult(ok=False, message=str(exc))
|
||||||
|
|
||||||
|
if not self._import_is_wired():
|
||||||
|
return ReverseProxyResult(
|
||||||
|
ok=False,
|
||||||
|
message=(
|
||||||
|
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
|
||||||
|
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = self._read_snippet()
|
||||||
|
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||||
|
entries.append(_Entry(host=clean_host, port=port))
|
||||||
|
updated = self._render(entries)
|
||||||
|
|
||||||
|
self._write_snippet(updated)
|
||||||
|
error = await self._validate()
|
||||||
|
if error is None:
|
||||||
|
error = await self._reload()
|
||||||
|
|
||||||
|
if error is not None:
|
||||||
|
# Roll back so a failed change never leaves a half-written config.
|
||||||
|
self._write_snippet(previous)
|
||||||
|
await self._reload()
|
||||||
|
return ReverseProxyResult(ok=False, message=error)
|
||||||
|
|
||||||
|
return ReverseProxyResult(
|
||||||
|
ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||||
|
"""Remove a route.
|
||||||
|
|
||||||
|
``domain_id`` is the host, matching how the application layer calls this
|
||||||
|
(``remove_preview_domain(domain_id=record["host"])``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
clean_host = validate_managed_host(domain_id, self.root_domain)
|
||||||
|
except RuleViolation:
|
||||||
|
# Not one of ours — nothing to remove, and not an error.
|
||||||
|
return
|
||||||
|
|
||||||
|
previous = self._read_snippet()
|
||||||
|
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||||
|
if len(entries) == len(self._parse(previous)):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._write_snippet(self._render(entries))
|
||||||
|
error = await self._validate()
|
||||||
|
if error is None:
|
||||||
|
error = await self._reload()
|
||||||
|
if error is not None:
|
||||||
|
self._write_snippet(previous)
|
||||||
|
await self._reload()
|
||||||
|
|
||||||
|
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||||
|
"""Every managed route in the snippet.
|
||||||
|
|
||||||
|
NOT tenant-scoped: the snippet records hosts and ports, not projects, and
|
||||||
|
``configure_reverse_proxy`` is not given a project id. The database
|
||||||
|
(``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger —
|
||||||
|
never use this method for authorization.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
|
||||||
|
for e in self._parse(self._read_snippet())
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- file handling -------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_snippet(self) -> str:
|
||||||
|
if not self.snippet_path.exists():
|
||||||
|
return ""
|
||||||
|
return self.snippet_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def _write_snippet(self, content: str) -> None:
|
||||||
|
"""Write atomically: a reader must never see a partial file."""
|
||||||
|
try:
|
||||||
|
self.snippet_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self.snippet_path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(content, encoding="utf-8")
|
||||||
|
tmp.replace(self.snippet_path)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _import_is_wired(self) -> bool:
|
||||||
|
"""Confirm the Caddyfile actually imports our snippet."""
|
||||||
|
try:
|
||||||
|
text = self.caddyfile_path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
needle = str(self.snippet_dir)
|
||||||
|
return any(
|
||||||
|
line.strip().startswith("import") and needle in line
|
||||||
|
for line in text.splitlines()
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- rendering and parsing ----------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _render(entries: list[_Entry]) -> str:
|
||||||
|
header = (
|
||||||
|
"# This file is generated and owned by AGY (web preview domains).\n"
|
||||||
|
"# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n"
|
||||||
|
)
|
||||||
|
blocks = []
|
||||||
|
for entry in sorted(entries, key=lambda e: e.host):
|
||||||
|
blocks.append(
|
||||||
|
f"{_BEGIN.format(host=entry.host)}\n"
|
||||||
|
f"{entry.host} {{\n"
|
||||||
|
f"\treverse_proxy localhost:{entry.port}\n"
|
||||||
|
f"}}\n"
|
||||||
|
f"{_END.format(host=entry.host)}\n"
|
||||||
|
)
|
||||||
|
return header + "\n".join(blocks)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(text: str) -> list[_Entry]:
|
||||||
|
entries: list[_Entry] = []
|
||||||
|
for match in _BLOCK_RE.finditer(text or ""):
|
||||||
|
port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body"))
|
||||||
|
if not port_match:
|
||||||
|
continue
|
||||||
|
entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1))))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
# -- caddy process -------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run(self, *args: str, timeout: int) -> tuple[int, str]:
|
||||||
|
"""Run the ``caddy`` binary. Platform process, never tenant code."""
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
self.caddy_binary,
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
return 1, "عملیات Caddy زمانبر شد."
|
||||||
|
|
||||||
|
combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip()
|
||||||
|
return process.returncode or 0, combined
|
||||||
|
|
||||||
|
async def _validate(self) -> str | None:
|
||||||
|
"""Return an error message, or ``None`` when the config is valid."""
|
||||||
|
code, output = await self._run(
|
||||||
|
"validate", "--config", str(self.caddyfile_path),
|
||||||
|
timeout=_VALIDATE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return f"پیکربندی Caddy نامعتبر است: {output[:400]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _reload(self) -> str | None:
|
||||||
|
code, output = await self._run(
|
||||||
|
"reload", "--config", str(self.caddyfile_path),
|
||||||
|
timeout=_RELOAD_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Entry:
|
||||||
|
"""One managed route."""
|
||||||
|
|
||||||
|
__slots__ = ("host", "port")
|
||||||
|
|
||||||
|
def __init__(self, *, host: str, port: int) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Gitea adapter for :class:`GitProviderPort`.
|
||||||
|
|
||||||
|
Talked to over the HTTP API with an admin token. The token is held by this
|
||||||
|
adapter only and is never placed on an object that a response could serialise.
|
||||||
|
|
||||||
|
Design decisions
|
||||||
|
----------------
|
||||||
|
|
||||||
|
**The adapter is stateless.** It is handed ``owner`` / ``repo_name`` by the
|
||||||
|
application layer instead of keeping a ``project_id`` map. A map would be lost on
|
||||||
|
restart and could drift from ``ProjectRepository``, so the database stays the one
|
||||||
|
source of truth for repository identity.
|
||||||
|
|
||||||
|
**Repositories live in one namespace, credentials are per project.** All
|
||||||
|
repositories are created under a single owner (the configured admin user) so
|
||||||
|
tenant repos cannot collide, and every project gets its *own* Gitea user that is
|
||||||
|
added as a collaborator on that one repository. A leaked clone credential
|
||||||
|
therefore reaches exactly one repository — least privilege rather than a shared
|
||||||
|
platform token.
|
||||||
|
|
||||||
|
**Names are re-validated here.** The application layer already produces safe
|
||||||
|
slugs, but the adapter must not depend on a caller behaving well: an unvalidated
|
||||||
|
name is the classic path to a path-traversal or an unintended API path segment.
|
||||||
|
|
||||||
|
Reference: ``docs/current-infra-notes.md`` §4.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.application.ports import (
|
||||||
|
BranchInfo,
|
||||||
|
CommitInfo,
|
||||||
|
GitCredentials,
|
||||||
|
MergeResult,
|
||||||
|
PullRequestInfo,
|
||||||
|
RepoInfo,
|
||||||
|
RepoStatus,
|
||||||
|
)
|
||||||
|
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||||
|
|
||||||
|
#: Gitea accepts ``[a-zA-Z0-9._-]`` for repository names; we are stricter and
|
||||||
|
#: keep the platform's lowercase-slug convention.
|
||||||
|
_REPO_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||||
|
#: Gitea usernames: alphanumeric plus ``-``/``_``, at most 40 characters, and
|
||||||
|
#: they may not start or end with a separator.
|
||||||
|
_USERNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]{1,38}[a-z0-9])$")
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||||
|
#: Cloning/committing can be slower than a metadata read.
|
||||||
|
WRITE_TIMEOUT_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaGitAdapter:
|
||||||
|
"""Git provider backed by a Gitea instance."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
admin_user: str,
|
||||||
|
admin_token: str,
|
||||||
|
owner: str | None = None,
|
||||||
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
if not base_url:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_INTERNAL_URL یا GITEA_URL است."
|
||||||
|
)
|
||||||
|
if not admin_token:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_ADMIN_TOKEN است."
|
||||||
|
)
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.admin_user = admin_user
|
||||||
|
self._admin_token = admin_token
|
||||||
|
#: Namespace that owns every project repository.
|
||||||
|
self.owner = owner or admin_user
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
# -- plumbing ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _client(self, *, write: bool = False) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self._admin_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=WRITE_TIMEOUT_SECONDS if write else self.timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_repo_name(repo_name: str) -> str:
|
||||||
|
clean = (repo_name or "").strip().lower()
|
||||||
|
if not _REPO_NAME_RE.match(clean):
|
||||||
|
raise ValidationError(
|
||||||
|
"نام مخزن نامعتبر است؛ فقط حروف کوچک، عدد، نقطه، خط تیره و زیرخط مجاز است."
|
||||||
|
)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_username(username: str) -> str:
|
||||||
|
clean = (username or "").strip().lower()
|
||||||
|
if not _USERNAME_RE.match(clean):
|
||||||
|
raise ValidationError("نام کاربری مخزن نامعتبر است.")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _seg(value: str) -> str:
|
||||||
|
"""Percent-encode a path segment so it cannot break out of the path."""
|
||||||
|
return quote(value, safe="")
|
||||||
|
|
||||||
|
async def _send(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
write: bool = False,
|
||||||
|
expect: tuple[int, ...] = (200, 201),
|
||||||
|
**kwargs,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Perform a request and translate transport failures.
|
||||||
|
|
||||||
|
A network problem must surface as ``ProviderUnavailableError`` (a 503),
|
||||||
|
never as a raw ``httpx`` exception leaking through the API.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._client(write=write) as client:
|
||||||
|
response = await client.request(method, path, **kwargs)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderUnavailableError("ارتباط با Gitea زمانبر شد.") from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||||
|
|
||||||
|
if response.status_code not in expect:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {method} {path}."
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def _get_json(self, path: str, *, expect: tuple[int, ...] = (200,)) -> dict | list | None:
|
||||||
|
"""GET that tolerates 404 by returning ``None``."""
|
||||||
|
try:
|
||||||
|
async with self._client() as client:
|
||||||
|
response = await client.get(path)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
if response.status_code not in expect:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {path}."
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# -- repository lifecycle ------------------------------------------------
|
||||||
|
|
||||||
|
async def ensure_project_repo(
|
||||||
|
self, *, project_id: str, repo_name: str, description: str = ""
|
||||||
|
) -> RepoInfo:
|
||||||
|
name = self._sanitize_repo_name(repo_name)
|
||||||
|
owner = self._sanitize_username(self.owner)
|
||||||
|
path = f"/api/v1/repos/{self._seg(owner)}/{self._seg(name)}"
|
||||||
|
|
||||||
|
existing = await self._get_json(path)
|
||||||
|
if existing is None:
|
||||||
|
# The admin endpoint creates the repository inside the owner's
|
||||||
|
# namespace, which a plain user token could not do.
|
||||||
|
await self._send(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/admin/users/{self._seg(owner)}/repos",
|
||||||
|
json={
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"private": True,
|
||||||
|
"auto_init": True,
|
||||||
|
"default_branch": "main",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
existing = await self._get_json(path)
|
||||||
|
if existing is None:
|
||||||
|
raise ProviderUnavailableError("ساخت مخزن در Gitea تأیید نشد.")
|
||||||
|
|
||||||
|
if not isinstance(existing, dict):
|
||||||
|
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||||
|
return self._to_repo_info(existing, fallback_name=name, fallback_owner=owner)
|
||||||
|
|
||||||
|
def _to_repo_info(self, payload: dict, *, fallback_name: str, fallback_owner: str) -> RepoInfo:
|
||||||
|
owner = ((payload.get("owner") or {}).get("login")) or fallback_owner
|
||||||
|
name = payload.get("name") or fallback_name
|
||||||
|
return RepoInfo(
|
||||||
|
owner=owner,
|
||||||
|
repo_name=name,
|
||||||
|
clone_url=payload.get("clone_url") or f"{self.base_url}/{owner}/{name}.git",
|
||||||
|
web_url=payload.get("html_url") or f"{self.base_url}/{owner}/{name}",
|
||||||
|
default_branch=payload.get("default_branch") or "main",
|
||||||
|
# Never let a remote payload make a tenant repository public.
|
||||||
|
private=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ensure_credentials(
|
||||||
|
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||||
|
) -> GitCredentials:
|
||||||
|
"""Create (or reset) a dedicated Gitea user for this project.
|
||||||
|
|
||||||
|
The user is added as a *collaborator on one repository only*, so the
|
||||||
|
returned password cannot reach any other tenant's code.
|
||||||
|
"""
|
||||||
|
repo_owner = self._sanitize_username(owner or self.owner)
|
||||||
|
name = self._sanitize_repo_name(repo_name)
|
||||||
|
username = self._sanitize_username(f"proj-{project_id[:24]}")
|
||||||
|
|
||||||
|
# A long random password, generated fresh every call. Gitea hashes it.
|
||||||
|
password = secrets.token_urlsafe(24)
|
||||||
|
email = f"{username}@users.noreply.agy.invalid"
|
||||||
|
|
||||||
|
created = await self._send(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
expect=(200, 201, 422),
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"must_change_password": False,
|
||||||
|
"visibility": "private",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created.status_code == 422:
|
||||||
|
# Already exists — rotate its password instead of failing, so a
|
||||||
|
# retried `ensure_credentials` stays idempotent.
|
||||||
|
await self._send(
|
||||||
|
"PATCH",
|
||||||
|
f"/api/v1/admin/users/{self._seg(username)}",
|
||||||
|
json={"password": password, "must_change_password": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grant write access to exactly this repository.
|
||||||
|
await self._send(
|
||||||
|
"PUT",
|
||||||
|
f"/api/v1/repos/{self._seg(repo_owner)}/{self._seg(name)}"
|
||||||
|
f"/collaborators/{self._seg(username)}",
|
||||||
|
json={"permission": "write"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return GitCredentials(username=username, password=password)
|
||||||
|
|
||||||
|
# -- branches and commits ------------------------------------------------
|
||||||
|
|
||||||
|
async def create_branch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
branch_name: str,
|
||||||
|
from_branch: str,
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> BranchInfo:
|
||||||
|
base_path = self._repo_path(owner, repo_name)
|
||||||
|
response = await self._send(
|
||||||
|
"POST",
|
||||||
|
f"{base_path}/branches",
|
||||||
|
expect=(200, 201),
|
||||||
|
json={"new_branch_name": branch_name, "old_branch_name": from_branch},
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
commit = payload.get("commit") or {}
|
||||||
|
return BranchInfo(name=payload.get("name", branch_name), sha=commit.get("id", ""))
|
||||||
|
|
||||||
|
async def commit_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
branch_name: str,
|
||||||
|
files: dict[str, str],
|
||||||
|
message: str,
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> CommitInfo:
|
||||||
|
"""Commit every file, one Contents-API call each.
|
||||||
|
|
||||||
|
Gitea's contents endpoint handles a single file per request, so a
|
||||||
|
multi-file change is a sequence of commits. Each update needs the blob's
|
||||||
|
current ``sha``, which we read first.
|
||||||
|
"""
|
||||||
|
base_path = self._repo_path(owner, repo_name)
|
||||||
|
last_sha = ""
|
||||||
|
changed = 0
|
||||||
|
|
||||||
|
for raw_path, content in files.items():
|
||||||
|
path = self._safe_content_path(raw_path)
|
||||||
|
encoded = quote(path, safe="/")
|
||||||
|
existing = await self._get_json(f"{base_path}/contents/{encoded}?ref={branch_name}")
|
||||||
|
body = {
|
||||||
|
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||||
|
"message": message,
|
||||||
|
"branch": branch_name,
|
||||||
|
}
|
||||||
|
if isinstance(existing, dict) and existing.get("sha"):
|
||||||
|
body["sha"] = existing["sha"]
|
||||||
|
response = await self._send(
|
||||||
|
"PUT", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await self._send(
|
||||||
|
"POST", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
last_sha = (payload.get("commit") or {}).get("sha") or last_sha
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
return CommitInfo(
|
||||||
|
sha=last_sha, branch=branch_name, message=message, files_changed=changed
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_content_path(raw_path: str) -> str:
|
||||||
|
"""Reject absolute paths and traversal before they reach the URL."""
|
||||||
|
clean = (raw_path or "").strip().lstrip("/")
|
||||||
|
if not clean:
|
||||||
|
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||||
|
segments = clean.split("/")
|
||||||
|
if any(seg in {"", ".", ".."} for seg in segments):
|
||||||
|
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||||
|
return "/".join(segments)
|
||||||
|
|
||||||
|
# -- pull requests -------------------------------------------------------
|
||||||
|
|
||||||
|
async def open_pull_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
head: str,
|
||||||
|
base: str,
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> PullRequestInfo:
|
||||||
|
response = await self._send(
|
||||||
|
"POST",
|
||||||
|
f"{self._repo_path(owner, repo_name)}/pulls",
|
||||||
|
expect=(200, 201),
|
||||||
|
json={"head": head, "base": base, "title": title, "body": body},
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
return PullRequestInfo(
|
||||||
|
number=int(payload.get("number", 0)),
|
||||||
|
url=payload.get("html_url", ""),
|
||||||
|
state=payload.get("state", "open"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def merge_pull_request(
|
||||||
|
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||||
|
) -> MergeResult:
|
||||||
|
try:
|
||||||
|
await self._send(
|
||||||
|
"POST",
|
||||||
|
f"{self._repo_path(owner, repo_name)}/pulls/{int(pr_number)}/merge",
|
||||||
|
json={"Do": "merge"},
|
||||||
|
)
|
||||||
|
except ProviderUnavailableError as exc:
|
||||||
|
# A merge conflict is a normal outcome, not an outage.
|
||||||
|
return MergeResult(merged=False, message=str(exc))
|
||||||
|
return MergeResult(merged=True, message="PR ادغام شد.")
|
||||||
|
|
||||||
|
# -- status --------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_repo_status(
|
||||||
|
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||||
|
) -> RepoStatus:
|
||||||
|
base_path = self._repo_path(owner, repo_name)
|
||||||
|
repo = await self._get_json(base_path)
|
||||||
|
if repo is None:
|
||||||
|
return RepoStatus(exists=False)
|
||||||
|
if not isinstance(repo, dict):
|
||||||
|
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||||
|
|
||||||
|
branches: list[str] = []
|
||||||
|
branch_payload = await self._get_json(f"{base_path}/branches")
|
||||||
|
if isinstance(branch_payload, list):
|
||||||
|
branches = sorted(b.get("name", "") for b in branch_payload if b.get("name"))
|
||||||
|
|
||||||
|
open_prs = 0
|
||||||
|
pr_payload = await self._get_json(f"{base_path}/pulls?state=open&limit=50")
|
||||||
|
if isinstance(pr_payload, list):
|
||||||
|
open_prs = len(pr_payload)
|
||||||
|
|
||||||
|
return RepoStatus(
|
||||||
|
exists=True,
|
||||||
|
private=True,
|
||||||
|
default_branch=repo.get("default_branch") or "main",
|
||||||
|
branches=branches,
|
||||||
|
open_pull_requests=open_prs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _repo_path(self, owner: str, repo_name: str) -> str:
|
||||||
|
"""Build ``/api/v1/repos/{owner}/{repo}`` from validated segments."""
|
||||||
|
if not repo_name:
|
||||||
|
raise ValidationError("نام مخزن مشخص نیست.")
|
||||||
|
clean_owner = self._sanitize_username(owner or self.owner)
|
||||||
|
clean_repo = self._sanitize_repo_name(repo_name)
|
||||||
|
return f"/api/v1/repos/{self._seg(clean_owner)}/{self._seg(clean_repo)}"
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"""Mock adapters — every port has one so the stack runs with zero credentials.
|
||||||
|
|
||||||
|
These are not toys: they enforce the same contracts (timeouts, output caps,
|
||||||
|
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
|
||||||
|
change application behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.application.ports import (
|
||||||
|
BranchInfo,
|
||||||
|
ChatMessage,
|
||||||
|
CheckoutSession,
|
||||||
|
CommandResult,
|
||||||
|
CommitInfo,
|
||||||
|
GitCredentials,
|
||||||
|
MergeResult,
|
||||||
|
ModelHealth,
|
||||||
|
ModelResponse,
|
||||||
|
Notification,
|
||||||
|
PaymentResult,
|
||||||
|
PreviewDomainInfo,
|
||||||
|
PullRequestInfo,
|
||||||
|
RepoInfo,
|
||||||
|
RepoStatus,
|
||||||
|
ReverseProxyResult,
|
||||||
|
SessionInfo,
|
||||||
|
SnapshotInfo,
|
||||||
|
StoredObject,
|
||||||
|
VectorHit,
|
||||||
|
WorkspaceInfo,
|
||||||
|
)
|
||||||
|
from app.domain.ai_control.models import AiModel
|
||||||
|
from app.domain.platform.rules import (
|
||||||
|
assert_port_allocatable,
|
||||||
|
build_host,
|
||||||
|
validate_managed_host,
|
||||||
|
validate_proxy_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MockModelProviderAdapter:
|
||||||
|
"""Deterministic, offline stand-in for an LLM.
|
||||||
|
|
||||||
|
Returns a canned Persian acknowledgement that names the task so tests can
|
||||||
|
assert on it without a network call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, latency_ms: int = 0) -> None:
|
||||||
|
self._latency_ms = latency_ms
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: AiModel,
|
||||||
|
credential=None,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
system: str = "",
|
||||||
|
temperature: float = 0.2,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
) -> ModelResponse:
|
||||||
|
started = time.perf_counter()
|
||||||
|
if self._latency_ms:
|
||||||
|
await asyncio.sleep(self._latency_ms / 1000)
|
||||||
|
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||||
|
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||||
|
# The canned text must read like model output, not like a debug tag: the
|
||||||
|
# provider/model identity is platform-internal and must never surface in
|
||||||
|
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
|
||||||
|
content = (
|
||||||
|
"این پاسخ آزمایشی است. "
|
||||||
|
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||||
|
)
|
||||||
|
return ModelResponse(
|
||||||
|
content=content,
|
||||||
|
model_internal_name=model.internal_name,
|
||||||
|
input_tokens=prompt_tokens + len(system.split()),
|
||||||
|
output_tokens=len(content.split()),
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
raw={"mock": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health(self) -> ModelHealth:
|
||||||
|
return ModelHealth(ok=True, detail="mock provider")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Git
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MockGitAdapter:
|
||||||
|
"""In-memory Git provider. Nothing touches a real forge.
|
||||||
|
|
||||||
|
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
|
||||||
|
adapter but ignored here — this adapter keys everything by ``project_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._repos: dict[str, RepoInfo] = {}
|
||||||
|
self._credentials: dict[str, GitCredentials] = {}
|
||||||
|
self._branches: dict[str, dict[str, str]] = {}
|
||||||
|
self._prs: dict[str, list[PullRequestInfo]] = {}
|
||||||
|
|
||||||
|
async def ensure_project_repo(
|
||||||
|
self, *, project_id: str, repo_name: str, description: str = ""
|
||||||
|
) -> RepoInfo:
|
||||||
|
if project_id not in self._repos:
|
||||||
|
self._repos[project_id] = RepoInfo(
|
||||||
|
owner="mock",
|
||||||
|
repo_name=repo_name,
|
||||||
|
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
|
||||||
|
web_url=f"https://git.example.invalid/mock/{repo_name}",
|
||||||
|
default_branch="main",
|
||||||
|
private=True,
|
||||||
|
)
|
||||||
|
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
|
||||||
|
self._prs[project_id] = []
|
||||||
|
return self._repos[project_id]
|
||||||
|
|
||||||
|
async def ensure_credentials(
|
||||||
|
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||||
|
) -> GitCredentials:
|
||||||
|
if project_id not in self._credentials:
|
||||||
|
self._credentials[project_id] = GitCredentials(
|
||||||
|
username=f"proj_{project_id[:8]}",
|
||||||
|
password=secrets.token_urlsafe(12),
|
||||||
|
)
|
||||||
|
return self._credentials[project_id]
|
||||||
|
|
||||||
|
async def create_branch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
branch_name: str,
|
||||||
|
from_branch: str,
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> BranchInfo:
|
||||||
|
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
|
||||||
|
base = branches.get(from_branch, "0" * 40)
|
||||||
|
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
|
||||||
|
branches[branch_name] = sha
|
||||||
|
return BranchInfo(name=branch_name, sha=sha)
|
||||||
|
|
||||||
|
async def commit_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
branch_name: str,
|
||||||
|
files: dict[str, str],
|
||||||
|
message: str,
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> CommitInfo:
|
||||||
|
digest = hashlib.sha1(
|
||||||
|
f"{branch_name}:{message}:{sorted(files)}".encode()
|
||||||
|
).hexdigest()[:40]
|
||||||
|
self._branches.setdefault(project_id, {})[branch_name] = digest
|
||||||
|
return CommitInfo(
|
||||||
|
sha=digest, branch=branch_name, message=message, files_changed=len(files)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def open_pull_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
head: str,
|
||||||
|
base: str,
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
owner: str = "",
|
||||||
|
repo_name: str = "",
|
||||||
|
) -> PullRequestInfo:
|
||||||
|
prs = self._prs.setdefault(project_id, [])
|
||||||
|
number = len(prs) + 1
|
||||||
|
repo = self._repos.get(project_id)
|
||||||
|
web = repo.web_url if repo else "https://git.example.invalid/mock"
|
||||||
|
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
|
||||||
|
prs.append(pr)
|
||||||
|
return pr
|
||||||
|
|
||||||
|
async def merge_pull_request(
|
||||||
|
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||||
|
) -> MergeResult:
|
||||||
|
prs = self._prs.setdefault(project_id, [])
|
||||||
|
for pr in prs:
|
||||||
|
if pr.number == pr_number:
|
||||||
|
pr.state = "merged"
|
||||||
|
return MergeResult(merged=True, message="ادغام شد (mock).")
|
||||||
|
return MergeResult(merged=False, message="PR یافت نشد.")
|
||||||
|
|
||||||
|
async def get_repo_status(
|
||||||
|
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||||
|
) -> RepoStatus:
|
||||||
|
repo = self._repos.get(project_id)
|
||||||
|
if repo is None:
|
||||||
|
return RepoStatus(exists=False)
|
||||||
|
return RepoStatus(
|
||||||
|
exists=True,
|
||||||
|
private=repo.private,
|
||||||
|
default_branch=repo.default_branch,
|
||||||
|
branches=sorted(self._branches.get(project_id, {}).keys()),
|
||||||
|
open_pull_requests=sum(
|
||||||
|
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MockDomainRouterAdapter:
|
||||||
|
"""In-memory port + subdomain ledger.
|
||||||
|
|
||||||
|
Applies the *same* validation rules as the real Caddy adapter, so tests that
|
||||||
|
pass here describe behaviour the production adapter also guarantees.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
root_domain: str = "msa.artacloud.ir",
|
||||||
|
port_start: int = 8080,
|
||||||
|
port_end: int = 8999,
|
||||||
|
) -> None:
|
||||||
|
self.root_domain = root_domain
|
||||||
|
self.port_start = port_start
|
||||||
|
self.port_end = port_end
|
||||||
|
self._ports: dict[tuple[str, str], int] = {}
|
||||||
|
self._domains: dict[str, PreviewDomainInfo] = {}
|
||||||
|
self._project_domains: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
async def allocate_port(
|
||||||
|
self, *, organization_id: str, project_id: str, service_name: str
|
||||||
|
) -> int:
|
||||||
|
key = (project_id, service_name)
|
||||||
|
if key in self._ports:
|
||||||
|
return self._ports[key]
|
||||||
|
used = set(self._ports.values())
|
||||||
|
for port in range(self.port_start, self.port_end + 1):
|
||||||
|
if port in used:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
self._ports[key] = port
|
||||||
|
return port
|
||||||
|
raise RuntimeError("No available ports in the configured range.")
|
||||||
|
|
||||||
|
async def claim_preview_domain(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
project_id: str,
|
||||||
|
requested_subdomain: str,
|
||||||
|
port: int,
|
||||||
|
) -> PreviewDomainInfo:
|
||||||
|
host = build_host(requested_subdomain, self.root_domain)
|
||||||
|
if host in self._domains:
|
||||||
|
owner_domains = self._project_domains.get(project_id, [])
|
||||||
|
if host not in owner_domains:
|
||||||
|
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||||
|
info = PreviewDomainInfo(
|
||||||
|
host=host, port=port, url=f"https://{host}", status="active"
|
||||||
|
)
|
||||||
|
self._domains[host] = info
|
||||||
|
self._project_domains.setdefault(project_id, []).append(host)
|
||||||
|
return info
|
||||||
|
|
||||||
|
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||||
|
validate_managed_host(host, self.root_domain)
|
||||||
|
validate_proxy_target(f"localhost:{port}")
|
||||||
|
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
|
||||||
|
|
||||||
|
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||||
|
for host, info in list(self._domains.items()):
|
||||||
|
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
|
||||||
|
self._domains.pop(host, None)
|
||||||
|
self._domains.pop(domain_id, None)
|
||||||
|
for hosts in self._project_domains.values():
|
||||||
|
if domain_id in hosts:
|
||||||
|
hosts.remove(domain_id)
|
||||||
|
|
||||||
|
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||||
|
return [
|
||||||
|
self._domains[h]
|
||||||
|
for h in self._project_domains.get(project_id, [])
|
||||||
|
if h in self._domains
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sandbox
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LocalMockSandboxAdapter:
|
||||||
|
"""Executes nothing. Returns plausible results and records commands.
|
||||||
|
|
||||||
|
``run_command`` still honours the timeout and output cap contract so callers
|
||||||
|
cannot depend on behaviour the real adapters will not provide.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_output_bytes: int = 200_000) -> None:
|
||||||
|
self._workspaces: dict[str, str] = {}
|
||||||
|
self._sessions: dict[str, str] = {}
|
||||||
|
self._files: dict[str, dict[str, str]] = {}
|
||||||
|
self.commands: list[str] = []
|
||||||
|
self.max_output_bytes = max_output_bytes
|
||||||
|
|
||||||
|
async def create_workspace(
|
||||||
|
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||||
|
) -> WorkspaceInfo:
|
||||||
|
external_id = f"ws_{uuid.uuid4().hex[:12]}"
|
||||||
|
self._workspaces[external_id] = image
|
||||||
|
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
|
||||||
|
|
||||||
|
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||||
|
external_id = f"ses_{uuid.uuid4().hex[:12]}"
|
||||||
|
self._sessions[external_id] = workspace_external_id
|
||||||
|
self._files.setdefault(external_id, {})
|
||||||
|
return SessionInfo(external_id=external_id, status="running")
|
||||||
|
|
||||||
|
async def run_command(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_external_id: str,
|
||||||
|
command: str,
|
||||||
|
timeout_seconds: int,
|
||||||
|
max_output_bytes: int | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> CommandResult:
|
||||||
|
limit = max_output_bytes or self.max_output_bytes
|
||||||
|
started = time.perf_counter()
|
||||||
|
self.commands.append(command)
|
||||||
|
stdout = f"[mock] اجرا شد: {command}"
|
||||||
|
truncated = len(stdout.encode()) > limit
|
||||||
|
if truncated:
|
||||||
|
stdout = stdout.encode()[:limit].decode(errors="ignore")
|
||||||
|
return CommandResult(
|
||||||
|
exit_code=0,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr="",
|
||||||
|
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
|
||||||
|
truncated=truncated,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||||
|
return self._files.get(session_external_id, {}).get(path, "")
|
||||||
|
|
||||||
|
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||||
|
self._files.setdefault(session_external_id, {})[path] = content
|
||||||
|
|
||||||
|
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||||
|
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
|
||||||
|
|
||||||
|
async def pause(self, *, session_external_id: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||||
|
return SessionInfo(external_id=session_external_id, status="running")
|
||||||
|
|
||||||
|
async def destroy(self, *, session_external_id: str) -> None:
|
||||||
|
self._sessions.pop(session_external_id, None)
|
||||||
|
self._files.pop(session_external_id, None)
|
||||||
|
|
||||||
|
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||||
|
return f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LocalMockPaymentAdapter:
|
||||||
|
async def create_checkout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
amount: float,
|
||||||
|
currency: str,
|
||||||
|
success_url: str,
|
||||||
|
cancel_url: str,
|
||||||
|
) -> CheckoutSession:
|
||||||
|
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
|
||||||
|
return CheckoutSession(
|
||||||
|
external_id=external_id,
|
||||||
|
url=f"{success_url}?mock_checkout={external_id}",
|
||||||
|
amount=amount,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def refund(
|
||||||
|
self, *, external_payment_id: str, amount: float | None = None
|
||||||
|
) -> PaymentResult:
|
||||||
|
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage / vector / notification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryStorageAdapter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._objects: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
|
||||||
|
self._objects[key] = data
|
||||||
|
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
|
||||||
|
|
||||||
|
async def get(self, *, key: str) -> bytes:
|
||||||
|
return self._objects.get(key, b"")
|
||||||
|
|
||||||
|
async def delete(self, *, key: str) -> None:
|
||||||
|
self._objects.pop(key, None)
|
||||||
|
|
||||||
|
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
|
||||||
|
return f"memory://{key}?expires={expires_seconds}"
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryVectorStoreAdapter:
|
||||||
|
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
|
||||||
|
|
||||||
|
async def upsert(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
chunks: Iterable[tuple[str, str, list[float]]],
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> int:
|
||||||
|
count = 0
|
||||||
|
for chunk_id, content, _embedding in chunks:
|
||||||
|
document_id = str((metadata or {}).get("document_id", ""))
|
||||||
|
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
query: str,
|
||||||
|
limit: int = 5,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> list[VectorHit]:
|
||||||
|
terms = {t for t in query.lower().split() if t}
|
||||||
|
scored: list[VectorHit] = []
|
||||||
|
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
|
||||||
|
if org != organization_id:
|
||||||
|
continue
|
||||||
|
if project_id and meta.get("project_id") not in (None, project_id):
|
||||||
|
continue
|
||||||
|
overlap = len(terms & set(content.lower().split()))
|
||||||
|
if overlap:
|
||||||
|
scored.append(
|
||||||
|
VectorHit(
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
document_id=document_id,
|
||||||
|
content=content,
|
||||||
|
score=float(overlap),
|
||||||
|
metadata=meta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scored.sort(key=lambda h: h.score, reverse=True)
|
||||||
|
return scored[:limit]
|
||||||
|
|
||||||
|
async def delete_document(self, *, document_id: str) -> None:
|
||||||
|
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
|
||||||
|
if doc_id == document_id:
|
||||||
|
self._chunks.pop(chunk_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleNotificationAdapter:
|
||||||
|
"""Records notifications instead of sending them."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent: list[Notification] = []
|
||||||
|
|
||||||
|
async def send(self, *, notification: Notification) -> bool:
|
||||||
|
self.sent.append(notification)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryAuditAdapter:
|
||||||
|
"""In-memory AuditPort, used by unit tests that do not want a database."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Records whatever the caller hands over; the port is untyped here.
|
||||||
|
self.events: list[Any] = []
|
||||||
|
|
||||||
|
async def record(self, *, event):
|
||||||
|
self.events.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def list_for_organization(
|
||||||
|
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||||
|
):
|
||||||
|
matching = [e for e in self.events if e.organization_id == organization_id]
|
||||||
|
return matching[offset : offset + limit]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Real model adapter for any OpenAI-compatible endpoint.
|
||||||
|
|
||||||
|
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
|
||||||
|
API key is read from the credential record (decrypted by the caller), never from
|
||||||
|
the environment at request time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
|
||||||
|
from app.core.errors import ProviderUnavailableError
|
||||||
|
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatibleModelAdapter:
|
||||||
|
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
|
||||||
|
self.base_url = (base_url or "").rstrip("/")
|
||||||
|
self.default_api_key = default_api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
|
||||||
|
if credential is not None and credential.encrypted_api_key:
|
||||||
|
try:
|
||||||
|
return decrypt(credential.encrypted_api_key)
|
||||||
|
except Exception: # noqa: BLE001 - fall through to the env default
|
||||||
|
pass
|
||||||
|
return self.default_api_key
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: AiModel,
|
||||||
|
credential: ModelCredential | None,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
system: str = "",
|
||||||
|
temperature: float = 0.2,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
) -> ModelResponse:
|
||||||
|
if not self.base_url:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
|
||||||
|
)
|
||||||
|
|
||||||
|
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
|
||||||
|
{"role": m.role, "content": m.content} for m in messages
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"model": model.internal_name,
|
||||||
|
"messages": payload_messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
api_key = self.default_api_key
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/chat/completions", json=payload, headers=headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"ارائهدهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderUnavailableError("اتصال به ارائهدهندهٔ مدل برقرار نشد.") from exc
|
||||||
|
|
||||||
|
choice = (data.get("choices") or [{}])[0]
|
||||||
|
content = (choice.get("message") or {}).get("content", "") or ""
|
||||||
|
usage = data.get("usage") or {}
|
||||||
|
return ModelResponse(
|
||||||
|
content=content,
|
||||||
|
model_internal_name=data.get("model", model.internal_name),
|
||||||
|
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||||
|
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
raw={"id": data.get("id", "")},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health(self) -> ModelHealth:
|
||||||
|
if not self.base_url:
|
||||||
|
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/models")
|
||||||
|
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return ModelHealth(ok=False, detail=str(exc))
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Adapter registry.
|
||||||
|
|
||||||
|
One place decides which concrete adapter backs each port, driven purely by the
|
||||||
|
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
|
||||||
|
clone runs with no credentials.
|
||||||
|
|
||||||
|
Adapters that hold process state (the mock Git forge, the mock port ledger) are
|
||||||
|
cached as singletons so behaviour is consistent within a process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import ProviderUnavailableError
|
||||||
|
from app.infrastructure.adapters import mock
|
||||||
|
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_model_provider():
|
||||||
|
provider = settings.ai_default_provider.lower()
|
||||||
|
if provider == "mock":
|
||||||
|
return mock.MockModelProviderAdapter()
|
||||||
|
if provider == "openai_compatible":
|
||||||
|
return OpenAICompatibleModelAdapter(
|
||||||
|
base_url=settings.openai_compatible_base_url,
|
||||||
|
default_api_key=settings.openai_compatible_api_key,
|
||||||
|
)
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||||
|
"مقادیر مجاز: mock | openai_compatible"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_git_provider():
|
||||||
|
provider = settings.git_provider.lower()
|
||||||
|
if provider == "mock":
|
||||||
|
return mock.MockGitAdapter()
|
||||||
|
if provider == "gitea":
|
||||||
|
from app.infrastructure.adapters.gitea import GiteaGitAdapter
|
||||||
|
|
||||||
|
return GiteaGitAdapter(
|
||||||
|
base_url=settings.gitea_internal_url or settings.gitea_url,
|
||||||
|
admin_user=settings.gitea_admin_user,
|
||||||
|
admin_token=settings.gitea_admin_token,
|
||||||
|
)
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"GIT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_domain_router():
|
||||||
|
provider = settings.domain_router.lower()
|
||||||
|
if provider == "mock":
|
||||||
|
return mock.MockDomainRouterAdapter(
|
||||||
|
root_domain=settings.preview_root_domain,
|
||||||
|
port_start=settings.preview_port_start,
|
||||||
|
port_end=settings.preview_port_end,
|
||||||
|
)
|
||||||
|
if provider == "caddy":
|
||||||
|
from app.infrastructure.adapters.caddy import CaddyDomainRouterAdapter
|
||||||
|
|
||||||
|
return CaddyDomainRouterAdapter(
|
||||||
|
caddyfile_path=settings.caddyfile_path,
|
||||||
|
root_domain=settings.preview_root_domain,
|
||||||
|
port_start=settings.preview_port_start,
|
||||||
|
port_end=settings.preview_port_end,
|
||||||
|
)
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"DOMAIN_ROUTER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | caddy"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_sandbox_provider():
|
||||||
|
provider = settings.sandbox_provider.lower()
|
||||||
|
if provider == "mock":
|
||||||
|
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
|
||||||
|
if provider in {"daytona", "e2b"}:
|
||||||
|
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
|
||||||
|
|
||||||
|
return build_remote_sandbox_adapter(
|
||||||
|
provider,
|
||||||
|
daytona_api_key=settings.daytona_api_key,
|
||||||
|
daytona_api_url=settings.daytona_api_url,
|
||||||
|
e2b_api_key=settings.e2b_api_key,
|
||||||
|
)
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_payment_provider():
|
||||||
|
provider = settings.payment_provider.lower()
|
||||||
|
if provider == "mock":
|
||||||
|
return mock.LocalMockPaymentAdapter()
|
||||||
|
if provider == "stripe":
|
||||||
|
from app.infrastructure.adapters.stripe_payments import StripePaymentAdapter
|
||||||
|
|
||||||
|
return StripePaymentAdapter(
|
||||||
|
secret_key=settings.stripe_secret_key,
|
||||||
|
webhook_secret=settings.stripe_webhook_secret,
|
||||||
|
)
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"PAYMENT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | stripe"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_storage():
|
||||||
|
return mock.InMemoryStorageAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_vector_store():
|
||||||
|
return mock.InMemoryVectorStoreAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_notifier():
|
||||||
|
return mock.ConsoleNotificationAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_adapters() -> None:
|
||||||
|
"""Drop cached singletons. Used by tests that need a clean slate."""
|
||||||
|
for fn in (
|
||||||
|
get_model_provider,
|
||||||
|
get_git_provider,
|
||||||
|
get_domain_router,
|
||||||
|
get_sandbox_provider,
|
||||||
|
get_payment_provider,
|
||||||
|
get_storage,
|
||||||
|
get_vector_store,
|
||||||
|
get_notifier,
|
||||||
|
):
|
||||||
|
fn.cache_clear()
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
|
||||||
|
|
||||||
|
This is the **only** place tenant code may execute. It never shells out on the
|
||||||
|
platform host: every command is sent to a remote, disposable sandbox through the
|
||||||
|
provider's official Python SDK.
|
||||||
|
|
||||||
|
Why the SDKs are optional extras
|
||||||
|
--------------------------------
|
||||||
|
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
|
||||||
|
the ``sandbox-remote`` extra so a default install stays small, and they are
|
||||||
|
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
|
||||||
|
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
|
||||||
|
|
||||||
|
Fail loudly, not silently
|
||||||
|
-------------------------
|
||||||
|
The two SDKs change their surface between minor releases. Rather than assume a
|
||||||
|
method exists and fail halfway through a customer's build, :meth:`preflight`
|
||||||
|
probes for every attribute this adapter relies on and refuses to construct the
|
||||||
|
adapter if any is missing. A version drift therefore surfaces as a clear
|
||||||
|
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
|
||||||
|
|
||||||
|
Known gap
|
||||||
|
---------
|
||||||
|
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
|
||||||
|
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
|
||||||
|
The mock provider implements snapshots fully, and the database already records
|
||||||
|
them, so only the remote path is affected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
|
||||||
|
from app.core.errors import ProviderUnavailableError
|
||||||
|
|
||||||
|
#: Never let a caller ask for an unbounded run.
|
||||||
|
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||||
|
#: Mirror of the platform output cap; enforced client-side as well because a
|
||||||
|
#: remote SDK is under no obligation to truncate.
|
||||||
|
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sdk(provider: str) -> Any:
|
||||||
|
"""Import the provider SDK, or explain exactly how to install it."""
|
||||||
|
try:
|
||||||
|
if provider == "daytona":
|
||||||
|
import daytona # noqa: PLC0415
|
||||||
|
|
||||||
|
return daytona
|
||||||
|
if provider == "e2b":
|
||||||
|
import e2b # noqa: PLC0415
|
||||||
|
|
||||||
|
return e2b
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"SDK «{provider}» نصب نیست. برای فعالسازی اجرا کنید: "
|
||||||
|
f"pip install 'agy-web-api[sandbox-remote]'"
|
||||||
|
) from exc
|
||||||
|
raise ProviderUnavailableError(f"ارائهدهندهٔ sandbox «{provider}» پشتیبانی نمیشود.")
|
||||||
|
|
||||||
|
|
||||||
|
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
|
||||||
|
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
|
||||||
|
if not hasattr(obj, attr):
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
|
||||||
|
"نسخهٔ پشتیبانیشده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
|
||||||
|
)
|
||||||
|
return getattr(obj, attr)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
||||||
|
encoded = text.encode("utf-8", errors="replace")
|
||||||
|
if len(encoded) <= limit:
|
||||||
|
return text, False
|
||||||
|
return encoded[:limit].decode("utf-8", errors="ignore"), True
|
||||||
|
|
||||||
|
|
||||||
|
class _RemoteSandboxAdapter:
|
||||||
|
"""Shared behaviour for both remote providers."""
|
||||||
|
|
||||||
|
provider_name = "remote"
|
||||||
|
|
||||||
|
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
|
||||||
|
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||||
|
self._client: Any = None
|
||||||
|
self._sandboxes: dict[str, Any] = {}
|
||||||
|
self._sessions: dict[str, str] = {}
|
||||||
|
|
||||||
|
# -- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
|
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def create_workspace(
|
||||||
|
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||||
|
) -> WorkspaceInfo:
|
||||||
|
client = await self._ensure_client()
|
||||||
|
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
|
||||||
|
external_id = str(
|
||||||
|
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
|
||||||
|
)
|
||||||
|
self._sandboxes[external_id] = sandbox
|
||||||
|
return WorkspaceInfo(
|
||||||
|
external_id=external_id,
|
||||||
|
status="ready",
|
||||||
|
raw={"provider": self.provider_name, "image": image},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||||
|
# Remote sandboxes are long-lived; a "session" is a handle onto one.
|
||||||
|
external_id = f"ses_{workspace_external_id}"
|
||||||
|
self._sessions[external_id] = workspace_external_id
|
||||||
|
return SessionInfo(external_id=external_id, status="running")
|
||||||
|
|
||||||
|
async def _sandbox_for_session(self, session_external_id: str) -> Any:
|
||||||
|
workspace_id = self._sessions.get(session_external_id, session_external_id)
|
||||||
|
sandbox = self._sandboxes.get(workspace_id)
|
||||||
|
if sandbox is None:
|
||||||
|
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
|
||||||
|
return sandbox
|
||||||
|
|
||||||
|
# -- execution -----------------------------------------------------------
|
||||||
|
|
||||||
|
async def run_command(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_external_id: str,
|
||||||
|
command: str,
|
||||||
|
timeout_seconds: int,
|
||||||
|
max_output_bytes: int | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> CommandResult:
|
||||||
|
sandbox = await self._sandbox_for_session(session_external_id)
|
||||||
|
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||||
|
# Clamp here too: the port contract says the caller can never ask for
|
||||||
|
# "no limit", and a bug upstream must not become an unbounded remote run.
|
||||||
|
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
|
||||||
|
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||||
|
|
||||||
|
# Pull the three fields out defensively; a missing one is a version
|
||||||
|
# mismatch and must fail with a precise message.
|
||||||
|
exit_code = _require(
|
||||||
|
raw, "exit_code", provider=self.provider_name, where="command result"
|
||||||
|
)
|
||||||
|
raw_stdout = _require(
|
||||||
|
raw, "stdout", provider=self.provider_name, where="command result"
|
||||||
|
)
|
||||||
|
raw_stderr = _require(
|
||||||
|
raw, "stderr", provider=self.provider_name, where="command result"
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
|
||||||
|
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
exit_code=int(exit_code or 0),
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
truncated=cut_out or cut_err,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- files ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||||
|
sandbox = await self._sandbox_for_session(session_external_id)
|
||||||
|
return await self._read(sandbox, path)
|
||||||
|
|
||||||
|
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||||
|
sandbox = await self._sandbox_for_session(session_external_id)
|
||||||
|
await self._write(sandbox, path, content)
|
||||||
|
|
||||||
|
# -- snapshots / preview -------------------------------------------------
|
||||||
|
|
||||||
|
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"snapshot بومی ارائهدهندهٔ راهدور هنوز فعال نشده است. "
|
||||||
|
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
|
||||||
|
"برای ارائهدهندهٔ خود پیادهسازی کنید."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pause(self, *, session_external_id: str) -> None:
|
||||||
|
sandbox = await self._sandbox_for_session(session_external_id)
|
||||||
|
await self._pause(sandbox)
|
||||||
|
|
||||||
|
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||||
|
sandbox = await self._sandbox_for_session(session_external_id)
|
||||||
|
await self._resume(sandbox)
|
||||||
|
return SessionInfo(external_id=session_external_id, status="running")
|
||||||
|
|
||||||
|
async def destroy(self, *, session_external_id: str) -> None:
|
||||||
|
workspace_id = self._sessions.pop(session_external_id, session_external_id)
|
||||||
|
sandbox = self._sandboxes.pop(workspace_id, None)
|
||||||
|
if sandbox is not None:
|
||||||
|
await self._destroy(sandbox)
|
||||||
|
|
||||||
|
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||||
|
sandbox = await self._sandbox_for_session(session_external_id)
|
||||||
|
return await self._preview_url(sandbox, port)
|
||||||
|
|
||||||
|
# -- provider hooks ------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _read(self, sandbox: Any, path: str) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _pause(self, sandbox: Any) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _resume(self, sandbox: Any) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _destroy(self, sandbox: Any) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
|
||||||
|
"""Daytona Cloud sandboxes via the async SDK.
|
||||||
|
|
||||||
|
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
|
||||||
|
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
|
||||||
|
``client.delete(sandbox)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_name = "daytona"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
|
||||||
|
) -> None:
|
||||||
|
super().__init__(max_output_bytes=max_output_bytes)
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
|
||||||
|
)
|
||||||
|
self._api_key = api_key
|
||||||
|
self._api_url = api_url
|
||||||
|
self._sdk = _load_sdk("daytona")
|
||||||
|
self._preflight()
|
||||||
|
|
||||||
|
def _preflight(self) -> None:
|
||||||
|
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
|
||||||
|
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
|
||||||
|
|
||||||
|
async def _ensure_client(self) -> Any:
|
||||||
|
if self._client is None:
|
||||||
|
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
||||||
|
if self._api_url:
|
||||||
|
config_kwargs["api_url"] = self._api_url
|
||||||
|
config = self._sdk.DaytonaConfig(**config_kwargs)
|
||||||
|
self._client = self._sdk.AsyncDaytona(config)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||||
|
# `create` accepts no arguments on the default image; a custom image is
|
||||||
|
# passed positionally as a snapshot/image reference.
|
||||||
|
try:
|
||||||
|
return await client.create()
|
||||||
|
except TypeError as exc:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
|
||||||
|
f"{exc}. نسخهٔ پشتیبانیشدهٔ daytona را نصب کنید."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||||
|
process = _require(sandbox, "process", provider="daytona", where="sandbox")
|
||||||
|
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
|
||||||
|
response = await exec_fn(command, timeout=timeout)
|
||||||
|
# Daytona reports the exit code separately from the combined output.
|
||||||
|
result = str(
|
||||||
|
_require(response, "result", provider="daytona", where="process response") or ""
|
||||||
|
)
|
||||||
|
return _DaytonaCommandOutput(
|
||||||
|
exit_code=int(getattr(response, "exit_code", 0) or 0),
|
||||||
|
stdout=result,
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _read(self, sandbox: Any, path: str) -> str:
|
||||||
|
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||||
|
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
|
||||||
|
content = await read_fn(path)
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
return content.decode("utf-8", errors="replace")
|
||||||
|
return str(content or "")
|
||||||
|
|
||||||
|
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||||
|
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||||
|
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
|
||||||
|
await write_fn(content.encode("utf-8"), path)
|
||||||
|
|
||||||
|
async def _pause(self, sandbox: Any) -> None:
|
||||||
|
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
|
||||||
|
await stop_fn()
|
||||||
|
|
||||||
|
async def _resume(self, sandbox: Any) -> None:
|
||||||
|
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
|
||||||
|
await start_fn()
|
||||||
|
|
||||||
|
async def _destroy(self, sandbox: Any) -> None:
|
||||||
|
client = await self._ensure_client()
|
||||||
|
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
|
||||||
|
await delete_fn(sandbox)
|
||||||
|
|
||||||
|
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||||
|
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
|
||||||
|
link = await link_fn(port)
|
||||||
|
return str(getattr(link, "url", link))
|
||||||
|
|
||||||
|
|
||||||
|
class E2BSandboxAdapter(_RemoteSandboxAdapter):
|
||||||
|
"""E2B sandboxes via the async SDK.
|
||||||
|
|
||||||
|
Targets the documented surface: ``AsyncSandbox.create(...)``,
|
||||||
|
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
|
||||||
|
``sandbox.kill()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_name = "e2b"
|
||||||
|
|
||||||
|
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
|
||||||
|
super().__init__(max_output_bytes=max_output_bytes)
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
|
||||||
|
)
|
||||||
|
self._api_key = api_key
|
||||||
|
self._sdk = _load_sdk("e2b")
|
||||||
|
self._preflight()
|
||||||
|
|
||||||
|
def _preflight(self) -> None:
|
||||||
|
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
|
||||||
|
|
||||||
|
async def _ensure_client(self) -> Any:
|
||||||
|
# E2B has no long-lived client object; the API key is passed per call.
|
||||||
|
return self._sdk.AsyncSandbox
|
||||||
|
|
||||||
|
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||||
|
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
|
||||||
|
return await create_fn(api_key=self._api_key)
|
||||||
|
|
||||||
|
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||||
|
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
|
||||||
|
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
|
||||||
|
result = await run_fn(command, timeout=timeout, envs=env or None)
|
||||||
|
return _E2BCommandOutput(
|
||||||
|
exit_code=int(getattr(result, "exit_code", 0) or 0),
|
||||||
|
stdout=str(getattr(result, "stdout", "") or ""),
|
||||||
|
stderr=str(getattr(result, "stderr", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _read(self, sandbox: Any, path: str) -> str:
|
||||||
|
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||||
|
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
|
||||||
|
content = await read_fn(path)
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
return content.decode("utf-8", errors="replace")
|
||||||
|
return str(content or "")
|
||||||
|
|
||||||
|
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||||
|
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||||
|
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
|
||||||
|
await write_fn(path, content)
|
||||||
|
|
||||||
|
async def _pause(self, sandbox: Any) -> None:
|
||||||
|
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
|
||||||
|
await pause_fn()
|
||||||
|
|
||||||
|
async def _resume(self, sandbox: Any) -> None:
|
||||||
|
# E2B resumes by reconnecting to the sandbox id.
|
||||||
|
connect_fn = _require(
|
||||||
|
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
|
||||||
|
)
|
||||||
|
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
|
||||||
|
|
||||||
|
async def _destroy(self, sandbox: Any) -> None:
|
||||||
|
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
|
||||||
|
await kill_fn()
|
||||||
|
|
||||||
|
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||||
|
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
|
||||||
|
return f"https://{await host_fn(port)}"
|
||||||
|
|
||||||
|
|
||||||
|
class _DaytonaCommandOutput:
|
||||||
|
__slots__ = ("exit_code", "stdout", "stderr")
|
||||||
|
|
||||||
|
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
class _E2BCommandOutput:
|
||||||
|
__slots__ = ("exit_code", "stdout", "stderr")
|
||||||
|
|
||||||
|
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def build_remote_sandbox_adapter(
|
||||||
|
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
|
||||||
|
):
|
||||||
|
"""Factory used by the adapter registry."""
|
||||||
|
normalised = (provider or "").strip().lower()
|
||||||
|
if normalised == "daytona":
|
||||||
|
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
|
||||||
|
if normalised == "e2b":
|
||||||
|
return E2BSandboxAdapter(api_key=e2b_api_key)
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||||
|
)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Stripe adapter for :class:`PaymentProviderPort`.
|
||||||
|
|
||||||
|
Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the
|
||||||
|
two calls we need are stable, and it keeps the API process free of an extra
|
||||||
|
dependency and its release cadence.
|
||||||
|
|
||||||
|
Responsibility boundary
|
||||||
|
-----------------------
|
||||||
|
The internal usage ledger is the **source of truth** for what a tenant owes.
|
||||||
|
This adapter only moves money — it never decides a balance, and a successful
|
||||||
|
checkout does not by itself credit a wallet. The application layer settles the
|
||||||
|
payment and then writes the credit, so a webhook replayed twice cannot
|
||||||
|
double-credit.
|
||||||
|
|
||||||
|
Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding,
|
||||||
|
never float multiplication, so ``19.99`` cannot become ``1998``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.application.ports import CheckoutSession, PaymentResult
|
||||||
|
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||||
|
|
||||||
|
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||||
|
#: Stripe rejects signatures older than this; mirrored locally to fail early.
|
||||||
|
WEBHOOK_TOLERANCE_SECONDS = 300
|
||||||
|
#: Card networks require a minimum charge. Refuse early with a clear message
|
||||||
|
#: rather than letting Stripe return a confusing error.
|
||||||
|
MINIMUM_CHARGE_USD = Decimal("0.50")
|
||||||
|
|
||||||
|
|
||||||
|
class StripePaymentAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
secret_key: str,
|
||||||
|
webhook_secret: str = "",
|
||||||
|
api_base: str = STRIPE_API_BASE,
|
||||||
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
if not secret_key:
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است."
|
||||||
|
)
|
||||||
|
self._secret_key = secret_key
|
||||||
|
self._webhook_secret = webhook_secret
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
# -- port ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_checkout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
amount: float,
|
||||||
|
currency: str,
|
||||||
|
success_url: str,
|
||||||
|
cancel_url: str,
|
||||||
|
) -> CheckoutSession:
|
||||||
|
"""Create a one-off Checkout Session for a wallet top-up."""
|
||||||
|
minor_units = self._to_minor_units(amount)
|
||||||
|
currency_code = (currency or "usd").strip().lower()
|
||||||
|
|
||||||
|
form = {
|
||||||
|
"mode": "payment",
|
||||||
|
"success_url": success_url,
|
||||||
|
"cancel_url": cancel_url,
|
||||||
|
# Ties the Stripe object back to our tenant without exposing an id
|
||||||
|
# the customer can edit meaningfully.
|
||||||
|
"client_reference_id": organization_id,
|
||||||
|
"metadata[organization_id]": organization_id,
|
||||||
|
"line_items[0][quantity]": "1",
|
||||||
|
"line_items[0][price_data][currency]": currency_code,
|
||||||
|
"line_items[0][price_data][unit_amount]": str(minor_units),
|
||||||
|
"line_items[0][price_data][product_data][name]": "AGY credit top-up",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = await self._request("POST", "/checkout/sessions", data=form)
|
||||||
|
url = payload.get("url")
|
||||||
|
if not url:
|
||||||
|
raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.")
|
||||||
|
|
||||||
|
return CheckoutSession(
|
||||||
|
external_id=payload.get("id", ""),
|
||||||
|
url=url,
|
||||||
|
# Report back in major units so the caller never sees minor units.
|
||||||
|
amount=float(Decimal(payload.get("amount_total") or minor_units) / 100),
|
||||||
|
currency=(payload.get("currency") or currency_code).upper(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def refund(
|
||||||
|
self, *, external_payment_id: str, amount: float | None = None
|
||||||
|
) -> PaymentResult:
|
||||||
|
if not external_payment_id:
|
||||||
|
raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.")
|
||||||
|
|
||||||
|
form: dict[str, str] = {}
|
||||||
|
# A Checkout Session is not refundable directly; its PaymentIntent is.
|
||||||
|
# Accept either so a caller does not have to know Stripe's object graph.
|
||||||
|
if external_payment_id.startswith("cs_"):
|
||||||
|
session = await self._request(
|
||||||
|
"GET", f"/checkout/sessions/{external_payment_id}", params={}
|
||||||
|
)
|
||||||
|
intent = session.get("payment_intent")
|
||||||
|
if not intent:
|
||||||
|
return PaymentResult(
|
||||||
|
ok=False,
|
||||||
|
external_id=external_payment_id,
|
||||||
|
message="این پرداخت هنوز تکمیل نشده است.",
|
||||||
|
)
|
||||||
|
form["payment_intent"] = str(intent)
|
||||||
|
else:
|
||||||
|
form["payment_intent"] = external_payment_id
|
||||||
|
|
||||||
|
if amount is not None:
|
||||||
|
form["amount"] = str(self._to_minor_units(amount))
|
||||||
|
|
||||||
|
payload = await self._request("POST", "/refunds", data=form)
|
||||||
|
return PaymentResult(
|
||||||
|
ok=payload.get("status") in {"succeeded", "pending"},
|
||||||
|
external_id=payload.get("id", ""),
|
||||||
|
message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- webhook -------------------------------------------------------------
|
||||||
|
|
||||||
|
def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool:
|
||||||
|
"""Verify a ``Stripe-Signature`` header over the raw request body.
|
||||||
|
|
||||||
|
The body must be the *raw bytes* — re-serialising parsed JSON changes the
|
||||||
|
byte sequence and the HMAC will never match.
|
||||||
|
"""
|
||||||
|
if not self._webhook_secret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
timestamp = ""
|
||||||
|
signatures: list[str] = []
|
||||||
|
for part in (signature_header or "").split(","):
|
||||||
|
key, _, value = part.partition("=")
|
||||||
|
if key.strip() == "t":
|
||||||
|
timestamp = value.strip()
|
||||||
|
elif key.strip() == "v1":
|
||||||
|
signatures.append(value.strip())
|
||||||
|
|
||||||
|
if not timestamp or not signatures:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
age = abs(time.time() - int(timestamp))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if age > WEBHOOK_TOLERANCE_SECONDS:
|
||||||
|
# Replay protection.
|
||||||
|
return False
|
||||||
|
|
||||||
|
signed_payload = f"{timestamp}.".encode() + payload
|
||||||
|
expected = hmac.new(
|
||||||
|
self._webhook_secret.encode(), signed_payload, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
# Constant-time compare against every offered signature.
|
||||||
|
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
|
||||||
|
|
||||||
|
# -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_minor_units(amount: float) -> int:
|
||||||
|
value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if value <= 0:
|
||||||
|
raise ValidationError("مبلغ باید بزرگتر از صفر باشد.")
|
||||||
|
if value < MINIMUM_CHARGE_USD:
|
||||||
|
raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.")
|
||||||
|
return int(value * 100)
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
|
headers = {"Authorization": f"Bearer {self._secret_key}"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.api_base, timeout=self.timeout_seconds
|
||||||
|
) as client:
|
||||||
|
response = await client.request(method, path, headers=headers, **kwargs)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderUnavailableError("ارتباط با Stripe زمانبر شد.") from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail = (response.json().get("error") or {}).get("message", "")
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text[:200]
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
f"Stripe خطا برگرداند ({response.status_code}): {detail}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Persist failures so they can be diagnosed after the fact.
|
||||||
|
|
||||||
|
Three properties make this module different from an ordinary repository call,
|
||||||
|
and all three are load-bearing:
|
||||||
|
|
||||||
|
**It never raises.** This is called from inside exception handlers, workers and
|
||||||
|
startup code — the exact places where an exception is already in flight. A
|
||||||
|
recorder that throws turns a handled 500 into an unhandled one, or kills a
|
||||||
|
worker mid-batch. Every failure here is swallowed and reported on stderr.
|
||||||
|
|
||||||
|
**It uses its own session.** The caller's session is frequently unusable by the
|
||||||
|
time we get here: an ``IntegrityError`` leaves it in a failed transaction where
|
||||||
|
every subsequent statement raises ``PendingRollbackError``. Reusing it would
|
||||||
|
mean the errors hardest to record — the database errors — are exactly the ones
|
||||||
|
that never get recorded. So we open a short-lived session of our own, commit,
|
||||||
|
and close.
|
||||||
|
|
||||||
|
**It redacts before it writes.** Everything passes through
|
||||||
|
``app.core.redact`` first. Error payloads are where credentials concentrate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.database import SessionFactory
|
||||||
|
from app.core.redact import redact_mapping, redact_text
|
||||||
|
from app.domain.audit.models import (
|
||||||
|
ErrorEvent,
|
||||||
|
ErrorSeverity,
|
||||||
|
ErrorSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("agy.errors")
|
||||||
|
|
||||||
|
#: Field caps. An error log that accepts unbounded input is a denial-of-service
|
||||||
|
#: vector and a storage leak; a 2 MB stack trace helps nobody.
|
||||||
|
MAX_MESSAGE_CHARS = 4_000
|
||||||
|
MAX_STACK_CHARS = 16_000
|
||||||
|
MAX_METADATA_JSON_CHARS = 20_000
|
||||||
|
MAX_KIND_CHARS = 80
|
||||||
|
MAX_TYPE_CHARS = 200
|
||||||
|
MAX_PATH_CHARS = 512
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fingerprinting — collapse a thousand occurrences into one row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UUID_RE = re.compile(
|
||||||
|
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
||||||
|
r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
|
||||||
|
)
|
||||||
|
_HEX_RE = re.compile(r"\b[0-9a-fA-F]{8,}\b")
|
||||||
|
_NUMBER_RE = re.compile(r"\b\d+\b")
|
||||||
|
_QUOTED_RE = re.compile(r"'[^']{0,200}'|\"[^\"]{0,200}\"")
|
||||||
|
#: A path segment that is an entity id rather than a route name. All-hex catches
|
||||||
|
#: ``new_id()`` (32 hex chars); the digit+length rule catches other id styles.
|
||||||
|
#: Route words like ``organizations`` have no digits and survive untouched.
|
||||||
|
_ID_SEGMENT_RE = re.compile(r"^(?:[0-9a-fA-F]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_message(message: str) -> str:
|
||||||
|
"""Strip the variable parts of a message so equal bugs hash equally.
|
||||||
|
|
||||||
|
``"task 4f3a… not found"`` and ``"task 9b1c… not found"`` are one bug with
|
||||||
|
two victims; without this they are two unrelated rows.
|
||||||
|
"""
|
||||||
|
text = _UUID_RE.sub("{uuid}", message)
|
||||||
|
text = _HEX_RE.sub("{hex}", text)
|
||||||
|
text = _QUOTED_RE.sub("{value}", text)
|
||||||
|
return _NUMBER_RE.sub("{n}", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_path(path: str) -> str:
|
||||||
|
"""``/api/orgs/4f3a…/projects`` → ``/api/orgs/{id}/projects``."""
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return "/".join(
|
||||||
|
"{id}" if _ID_SEGMENT_RE.match(segment) else segment for segment in path.split("/")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_fingerprint(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
error_type: str,
|
||||||
|
method: str = "",
|
||||||
|
path: str = "",
|
||||||
|
message: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Stable identity for a failure, independent of tenant and occurrence.
|
||||||
|
|
||||||
|
Deliberately *excludes* organization, user and request id: the same bug
|
||||||
|
hitting two tenants is still one bug, and grouping it that way is the whole
|
||||||
|
point of a fingerprint.
|
||||||
|
"""
|
||||||
|
material = "\x1f".join(
|
||||||
|
(
|
||||||
|
kind.strip().lower(),
|
||||||
|
error_type.strip().lower(),
|
||||||
|
method.strip().upper(),
|
||||||
|
_normalise_path(path).lower(),
|
||||||
|
_normalise_message(message.strip())[:500],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return hashlib.sha256(material.encode("utf-8", "replace")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Truncation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(value: str, limit: int) -> str:
|
||||||
|
if len(value) <= limit:
|
||||||
|
return value
|
||||||
|
return value[:limit] + f"…[+{len(value) - limit} chars]"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Redact, then bound. Returns a dict that is always JSON-serialisable."""
|
||||||
|
if not metadata:
|
||||||
|
return {}
|
||||||
|
scrubbed = redact_mapping(metadata)
|
||||||
|
try:
|
||||||
|
encoded = json.dumps(scrubbed, ensure_ascii=False, default=str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"_note": "metadata was not JSON-serialisable"}
|
||||||
|
if len(encoded) <= MAX_METADATA_JSON_CHARS:
|
||||||
|
return scrubbed
|
||||||
|
# Too large to keep: retain the keys so the shape is still visible.
|
||||||
|
return {
|
||||||
|
"_note": f"metadata dropped: {len(encoded)} chars exceeds {MAX_METADATA_JSON_CHARS}",
|
||||||
|
"_keys": sorted(str(k) for k in scrubbed)[:50],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_exception_stack(exc: BaseException) -> str:
|
||||||
|
"""``traceback.format_exception`` without the chained-context noise limit."""
|
||||||
|
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_stderr(event: ErrorEvent, reason: str) -> None:
|
||||||
|
"""Last-resort output when the database write failed.
|
||||||
|
|
||||||
|
Goes to stderr rather than through ``logging`` so it survives even a
|
||||||
|
misconfigured logging setup — the one case where losing the error entirely
|
||||||
|
is unacceptable.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[error-log] could not persist error ({reason}) "
|
||||||
|
f"kind={event.kind} type={event.error_type} "
|
||||||
|
f"path={event.path} msg={event.message[:300]}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def record_error(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
message: str,
|
||||||
|
exc: BaseException | None = None,
|
||||||
|
stack: str = "",
|
||||||
|
source: str = ErrorSource.HTTP,
|
||||||
|
severity: str = ErrorSeverity.ERROR,
|
||||||
|
status_code: int | None = None,
|
||||||
|
method: str = "",
|
||||||
|
path: str = "",
|
||||||
|
organization_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
request_id: str = "",
|
||||||
|
ip: str = "",
|
||||||
|
user_agent: str = "",
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Write one error row. Returns its id, or ``None`` if recording failed.
|
||||||
|
|
||||||
|
Returning ``None`` rather than raising is the contract: callers treat error
|
||||||
|
recording as best-effort telemetry, never as a step that can fail a request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
error_type = type(exc).__name__ if exc is not None else ""
|
||||||
|
if not stack and exc is not None:
|
||||||
|
stack = format_exception_stack(exc)
|
||||||
|
if not message and exc is not None:
|
||||||
|
message = str(exc) or error_type
|
||||||
|
|
||||||
|
# Redact *before* fingerprinting is not required, but redact before
|
||||||
|
# *storage* absolutely is. Fingerprint the raw message so a secret that
|
||||||
|
# happens to appear in a message does not change the grouping.
|
||||||
|
fingerprint = compute_fingerprint(
|
||||||
|
kind=kind, error_type=error_type, method=method, path=path, message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
event = ErrorEvent(
|
||||||
|
kind=_clip(redact_text(kind), MAX_KIND_CHARS),
|
||||||
|
message=_clip(redact_text(message), MAX_MESSAGE_CHARS),
|
||||||
|
stack=_clip(redact_text(stack), MAX_STACK_CHARS),
|
||||||
|
metadata_json=_safe_metadata(metadata),
|
||||||
|
organization_id=organization_id,
|
||||||
|
user_id=user_id,
|
||||||
|
run_id=run_id,
|
||||||
|
source=source,
|
||||||
|
severity=severity,
|
||||||
|
error_type=_clip(redact_text(error_type), MAX_TYPE_CHARS),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
status_code=status_code,
|
||||||
|
method=_clip(method, 10),
|
||||||
|
path=_clip(redact_text(path), MAX_PATH_CHARS),
|
||||||
|
request_id=_clip(request_id, 64),
|
||||||
|
ip=_clip(redact_text(ip), 64),
|
||||||
|
user_agent=_clip(redact_text(user_agent), 255),
|
||||||
|
)
|
||||||
|
except Exception as build_exc: # pragma: no cover - redaction is defensive
|
||||||
|
logger.exception("error-log: could not build the error record: %s", build_exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# A dedicated session: the caller's may be mid-rollback, and a failed error
|
||||||
|
# write must not roll back whatever the caller was legitimately doing.
|
||||||
|
try:
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
from app.infrastructure.repositories.audit import ObservabilityRepository
|
||||||
|
|
||||||
|
await ObservabilityRepository(session).record_error(event)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as write_exc:
|
||||||
|
_to_stderr(event, type(write_exc).__name__)
|
||||||
|
logger.warning("error-log: persistence failed: %s", write_exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mirror to the application log so the console and the terminal agree.
|
||||||
|
logger.log(
|
||||||
|
logging.ERROR if severity != ErrorSeverity.WARNING else logging.WARNING,
|
||||||
|
"%s [%s] %s %s -> %s: %s (request_id=%s fingerprint=%s)",
|
||||||
|
severity.upper(),
|
||||||
|
source,
|
||||||
|
method or "-",
|
||||||
|
path or "-",
|
||||||
|
status_code if status_code is not None else "-",
|
||||||
|
event.message[:500],
|
||||||
|
request_id or "-",
|
||||||
|
fingerprint,
|
||||||
|
)
|
||||||
|
return event.id
|
||||||
|
|
||||||
|
|
||||||
|
async def record_exception(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
kind: str | None = None,
|
||||||
|
**context: Any,
|
||||||
|
) -> str | None:
|
||||||
|
"""``record_error`` for the common case where there is an exception object."""
|
||||||
|
return await record_error(
|
||||||
|
kind=kind or type(exc).__name__,
|
||||||
|
message=str(exc),
|
||||||
|
exc=exc,
|
||||||
|
**context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compute_fingerprint",
|
||||||
|
"format_exception_stack",
|
||||||
|
"record_error",
|
||||||
|
"record_exception",
|
||||||
|
]
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Repositories for the AI Control Plane.
|
||||||
|
|
||||||
|
Platform-owner-only data. The application layer is responsible for never
|
||||||
|
serialising any of it to a customer-facing response; these repositories just
|
||||||
|
move rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.ai_control.models import (
|
||||||
|
AiModel,
|
||||||
|
ModelCredential,
|
||||||
|
Provider,
|
||||||
|
ProviderType,
|
||||||
|
RateCard,
|
||||||
|
RateUnit,
|
||||||
|
RoutingMode,
|
||||||
|
RoutingPolicy,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
AiModelCredentialRow,
|
||||||
|
AiModelRow,
|
||||||
|
AiProviderRow,
|
||||||
|
RateCardRow,
|
||||||
|
RoutingPolicyRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_to_domain(row: AiProviderRow) -> Provider:
|
||||||
|
return Provider(
|
||||||
|
id=row.id, name=row.name, type=ProviderType(row.type),
|
||||||
|
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_to_domain(row: AiModelCredentialRow) -> ModelCredential:
|
||||||
|
return ModelCredential(
|
||||||
|
id=row.id, provider_id=row.provider_id, name=row.name,
|
||||||
|
encrypted_api_key=row.encrypted_api_key, is_active=row.is_active,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_to_domain(row: AiModelRow) -> AiModel:
|
||||||
|
return AiModel(
|
||||||
|
id=row.id, provider_id=row.provider_id, internal_name=row.internal_name,
|
||||||
|
display_name_for_admin=row.display_name_for_admin,
|
||||||
|
capabilities=list(row.capabilities or []),
|
||||||
|
input_price_per_1m=row.input_price_per_1m,
|
||||||
|
output_price_per_1m=row.output_price_per_1m,
|
||||||
|
is_active=row.is_active, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_to_domain(row: RoutingPolicyRow) -> RoutingPolicy:
|
||||||
|
return RoutingPolicy(
|
||||||
|
id=row.id, name=row.name, description=row.description,
|
||||||
|
mode=RoutingMode(row.mode),
|
||||||
|
fallback_policy_json=dict(row.fallback_policy_json or {}),
|
||||||
|
budget_limits_json=dict(row.budget_limits_json or {}),
|
||||||
|
is_default=row.is_default, is_active=row.is_active, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_card_to_domain(row: RateCardRow) -> RateCard:
|
||||||
|
return RateCard(
|
||||||
|
id=row.id, name=row.name, unit=RateUnit(row.unit),
|
||||||
|
customer_price=row.customer_price,
|
||||||
|
internal_cost_estimate=row.internal_cost_estimate,
|
||||||
|
is_active=row.is_active, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self) -> list[Provider]:
|
||||||
|
stmt = select(AiProviderRow).order_by(AiProviderRow.created_at)
|
||||||
|
return [_provider_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, provider_id: str) -> Provider | None:
|
||||||
|
row = await self.session.get(AiProviderRow, provider_id)
|
||||||
|
return _provider_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str) -> Provider | None:
|
||||||
|
stmt = select(AiProviderRow).where(AiProviderRow.name == name)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _provider_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, provider: Provider) -> Provider:
|
||||||
|
row = AiProviderRow(
|
||||||
|
id=provider.id, name=provider.name, type=provider.type.value,
|
||||||
|
base_url=provider.base_url, is_active=provider.is_active,
|
||||||
|
created_at=provider.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _provider_to_domain(row)
|
||||||
|
|
||||||
|
async def set_active(self, provider_id: str, active: bool) -> None:
|
||||||
|
row = await self.session.get(AiProviderRow, provider_id)
|
||||||
|
if row:
|
||||||
|
row.is_active = active
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class ModelCredentialRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_provider(self, provider_id: str) -> list[ModelCredential]:
|
||||||
|
stmt = select(AiModelCredentialRow).where(
|
||||||
|
AiModelCredentialRow.provider_id == provider_id
|
||||||
|
)
|
||||||
|
return [_credential_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, credential_id: str) -> ModelCredential | None:
|
||||||
|
row = await self.session.get(AiModelCredentialRow, credential_id)
|
||||||
|
return _credential_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def get_active_for_provider(self, provider_id: str) -> ModelCredential | None:
|
||||||
|
stmt = (
|
||||||
|
select(AiModelCredentialRow)
|
||||||
|
.where(
|
||||||
|
AiModelCredentialRow.provider_id == provider_id,
|
||||||
|
AiModelCredentialRow.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(AiModelCredentialRow.created_at.desc())
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalars().first()
|
||||||
|
return _credential_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, credential: ModelCredential) -> ModelCredential:
|
||||||
|
row = AiModelCredentialRow(
|
||||||
|
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||||
|
encrypted_api_key=credential.encrypted_api_key,
|
||||||
|
is_active=credential.is_active, created_at=credential.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _credential_to_domain(row)
|
||||||
|
|
||||||
|
|
||||||
|
class AiModelRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self, *, provider_id: str | None = None) -> list[AiModel]:
|
||||||
|
stmt = select(AiModelRow)
|
||||||
|
if provider_id:
|
||||||
|
stmt = stmt.where(AiModelRow.provider_id == provider_id)
|
||||||
|
stmt = stmt.order_by(AiModelRow.created_at)
|
||||||
|
return [_model_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, model_id: str) -> AiModel | None:
|
||||||
|
row = await self.session.get(AiModelRow, model_id)
|
||||||
|
return _model_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, model: AiModel) -> AiModel:
|
||||||
|
row = AiModelRow(
|
||||||
|
id=model.id, provider_id=model.provider_id,
|
||||||
|
internal_name=model.internal_name,
|
||||||
|
display_name_for_admin=model.display_name_for_admin,
|
||||||
|
capabilities=list(model.capabilities),
|
||||||
|
input_price_per_1m=model.input_price_per_1m,
|
||||||
|
output_price_per_1m=model.output_price_per_1m,
|
||||||
|
is_active=model.is_active, created_at=model.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _model_to_domain(row)
|
||||||
|
|
||||||
|
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
|
||||||
|
"""Choose a model for a routing mode.
|
||||||
|
|
||||||
|
Deterministic and explainable: filter by required capability, then take
|
||||||
|
the cheapest active model. A real implementation would add health and
|
||||||
|
latency signals; keeping it simple here makes the behaviour testable.
|
||||||
|
"""
|
||||||
|
required: set[str] = set()
|
||||||
|
if mode == RoutingMode.VISION:
|
||||||
|
required.add("vision")
|
||||||
|
if mode == RoutingMode.CODING:
|
||||||
|
required.add("code")
|
||||||
|
if mode == RoutingMode.REASONING:
|
||||||
|
required.add("reasoning")
|
||||||
|
|
||||||
|
candidates = [m for m in await self.list() if m.is_active]
|
||||||
|
if required:
|
||||||
|
filtered = [m for m in candidates if required.issubset(set(m.capabilities))]
|
||||||
|
if filtered:
|
||||||
|
candidates = filtered
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return min(candidates, key=lambda m: (m.input_price_per_1m, m.output_price_per_1m))
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingPolicyRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self) -> list[RoutingPolicy]:
|
||||||
|
stmt = select(RoutingPolicyRow).order_by(RoutingPolicyRow.created_at)
|
||||||
|
return [_policy_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, policy_id: str) -> RoutingPolicy | None:
|
||||||
|
row = await self.session.get(RoutingPolicyRow, policy_id)
|
||||||
|
return _policy_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def get_default(self) -> RoutingPolicy | None:
|
||||||
|
stmt = select(RoutingPolicyRow).where(RoutingPolicyRow.is_default.is_(True))
|
||||||
|
row = (await self.session.execute(stmt)).scalars().first()
|
||||||
|
return _policy_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, policy: RoutingPolicy) -> RoutingPolicy:
|
||||||
|
if policy.is_default:
|
||||||
|
await self._clear_defaults()
|
||||||
|
row = RoutingPolicyRow(
|
||||||
|
id=policy.id, name=policy.name, description=policy.description,
|
||||||
|
mode=policy.mode.value,
|
||||||
|
fallback_policy_json=policy.fallback_policy_json,
|
||||||
|
budget_limits_json=policy.budget_limits_json,
|
||||||
|
is_default=policy.is_default, is_active=policy.is_active,
|
||||||
|
created_at=policy.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _policy_to_domain(row)
|
||||||
|
|
||||||
|
async def _clear_defaults(self) -> None:
|
||||||
|
rows = (await self.session.execute(select(RoutingPolicyRow))).scalars().all()
|
||||||
|
for row in rows:
|
||||||
|
row.is_default = False
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class RateCardRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self) -> list[RateCard]:
|
||||||
|
stmt = select(RateCardRow).order_by(RateCardRow.unit)
|
||||||
|
return [_rate_card_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get_by_unit(self, unit: RateUnit) -> RateCard | None:
|
||||||
|
stmt = select(RateCardRow).where(RateCardRow.unit == unit.value)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _rate_card_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def upsert(self, card: RateCard) -> RateCard:
|
||||||
|
existing = await self.get_by_unit(card.unit)
|
||||||
|
if existing:
|
||||||
|
row = await self.session.get(RateCardRow, existing.id)
|
||||||
|
assert row is not None
|
||||||
|
row.customer_price = card.customer_price
|
||||||
|
row.internal_cost_estimate = card.internal_cost_estimate
|
||||||
|
row.name = card.name
|
||||||
|
row.is_active = card.is_active
|
||||||
|
await self.session.flush()
|
||||||
|
return _rate_card_to_domain(row)
|
||||||
|
return await self.create(card)
|
||||||
|
|
||||||
|
async def create(self, card: RateCard) -> RateCard:
|
||||||
|
row = RateCardRow(
|
||||||
|
id=card.id, name=card.name, unit=card.unit.value,
|
||||||
|
customer_price=card.customer_price,
|
||||||
|
internal_cost_estimate=card.internal_cost_estimate,
|
||||||
|
is_active=card.is_active, created_at=card.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _rate_card_to_domain(row)
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
"""Repositories for Audit & Observability and the Usage Ledger."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
|
||||||
|
from app.domain.ai_control.models import RateUnit
|
||||||
|
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
AuditEventRow,
|
||||||
|
ErrorEventRow,
|
||||||
|
QualityMetricRow,
|
||||||
|
SpanRow,
|
||||||
|
TraceRow,
|
||||||
|
UsageEventRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
|
||||||
|
return AuditEvent(
|
||||||
|
id=row.id,
|
||||||
|
organization_id=row.organization_id,
|
||||||
|
actor_user_id=row.actor_user_id,
|
||||||
|
action=row.action,
|
||||||
|
target_type=row.target_type,
|
||||||
|
target_id=row.target_id,
|
||||||
|
metadata_json=row.metadata_json or {},
|
||||||
|
ip_address=row.ip_address,
|
||||||
|
user_agent=row.user_agent,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_to_domain(row: ErrorEventRow) -> ErrorEvent:
|
||||||
|
return ErrorEvent(
|
||||||
|
id=row.id,
|
||||||
|
organization_id=row.organization_id,
|
||||||
|
run_id=row.run_id,
|
||||||
|
kind=row.kind,
|
||||||
|
message=row.message,
|
||||||
|
stack=row.stack,
|
||||||
|
metadata_json=row.metadata_json or {},
|
||||||
|
source=row.source,
|
||||||
|
severity=row.severity,
|
||||||
|
error_type=row.error_type,
|
||||||
|
fingerprint=row.fingerprint,
|
||||||
|
status_code=row.status_code,
|
||||||
|
method=row.method,
|
||||||
|
path=row.path,
|
||||||
|
user_id=row.user_id,
|
||||||
|
request_id=row.request_id,
|
||||||
|
ip=row.ip,
|
||||||
|
user_agent=row.user_agent,
|
||||||
|
resolved_at=row.resolved_at,
|
||||||
|
resolved_note=row.resolved_note,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditRepository:
|
||||||
|
"""Concrete ``AuditPort``. Append-only."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def record(self, *, event: AuditEvent) -> AuditEvent:
|
||||||
|
row = AuditEventRow(
|
||||||
|
id=event.id,
|
||||||
|
organization_id=event.organization_id,
|
||||||
|
actor_user_id=event.actor_user_id,
|
||||||
|
action=event.action,
|
||||||
|
target_type=event.target_type,
|
||||||
|
target_id=event.target_id,
|
||||||
|
metadata_json=event.metadata_json,
|
||||||
|
ip_address=event.ip_address,
|
||||||
|
user_agent=event.user_agent,
|
||||||
|
created_at=event.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _audit_to_domain(row)
|
||||||
|
|
||||||
|
async def list_for_organization(
|
||||||
|
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[AuditEvent]:
|
||||||
|
stmt = (
|
||||||
|
select(AuditEventRow)
|
||||||
|
.where(AuditEventRow.organization_id == organization_id)
|
||||||
|
.order_by(AuditEventRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_platform(self, *, limit: int = 100, offset: int = 0) -> list[AuditEvent]:
|
||||||
|
stmt = (
|
||||||
|
select(AuditEventRow)
|
||||||
|
.order_by(AuditEventRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_audit_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
class UsageLedgerRepository:
|
||||||
|
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def record(self, *, usage: UsageRecord) -> None:
|
||||||
|
total = usage.total_price or (usage.quantity * usage.unit_price)
|
||||||
|
row = UsageEventRow(
|
||||||
|
organization_id=usage.organization_id,
|
||||||
|
project_id=usage.project_id,
|
||||||
|
user_id=usage.user_id,
|
||||||
|
employee_id=usage.employee_id,
|
||||||
|
run_id=usage.run_id,
|
||||||
|
unit=usage.unit.value,
|
||||||
|
quantity=usage.quantity,
|
||||||
|
unit_price=usage.unit_price,
|
||||||
|
internal_cost_estimate=usage.internal_cost_estimate,
|
||||||
|
total_price=total,
|
||||||
|
metadata_json=usage.metadata_json,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def breakdown(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> UsageBreakdown:
|
||||||
|
filters = [UsageEventRow.organization_id == organization_id]
|
||||||
|
if since is not None:
|
||||||
|
filters.append(UsageEventRow.created_at >= since)
|
||||||
|
if until is not None:
|
||||||
|
filters.append(UsageEventRow.created_at <= until)
|
||||||
|
|
||||||
|
by_unit_stmt = (
|
||||||
|
select(
|
||||||
|
UsageEventRow.unit,
|
||||||
|
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
|
||||||
|
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||||
|
func.coalesce(func.sum(UsageEventRow.internal_cost_estimate), 0.0),
|
||||||
|
)
|
||||||
|
.where(*filters)
|
||||||
|
.group_by(UsageEventRow.unit)
|
||||||
|
)
|
||||||
|
by_unit = [
|
||||||
|
UsageAggregate(
|
||||||
|
unit=RateUnit(unit),
|
||||||
|
quantity=float(qty),
|
||||||
|
total_price=float(price),
|
||||||
|
internal_cost_estimate=float(cost),
|
||||||
|
)
|
||||||
|
for unit, qty, price, cost in (await self.session.execute(by_unit_stmt)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
emp_stmt = (
|
||||||
|
select(
|
||||||
|
UsageEventRow.employee_id,
|
||||||
|
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||||
|
)
|
||||||
|
.where(*filters, UsageEventRow.employee_id.is_not(None))
|
||||||
|
.group_by(UsageEventRow.employee_id)
|
||||||
|
)
|
||||||
|
by_employee = {
|
||||||
|
str(emp): float(total)
|
||||||
|
for emp, total in (await self.session.execute(emp_stmt)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
proj_stmt = (
|
||||||
|
select(
|
||||||
|
UsageEventRow.project_id,
|
||||||
|
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||||
|
)
|
||||||
|
.where(*filters, UsageEventRow.project_id.is_not(None))
|
||||||
|
.group_by(UsageEventRow.project_id)
|
||||||
|
)
|
||||||
|
by_project = {
|
||||||
|
str(proj): float(total)
|
||||||
|
for proj, total in (await self.session.execute(proj_stmt)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
return UsageBreakdown(
|
||||||
|
by_unit=by_unit,
|
||||||
|
by_employee=by_employee,
|
||||||
|
by_project=by_project,
|
||||||
|
total_price=sum(a.total_price for a in by_unit),
|
||||||
|
total_cost=sum(a.internal_cost_estimate for a in by_unit),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[UsageEventRow]:
|
||||||
|
stmt = (
|
||||||
|
select(UsageEventRow)
|
||||||
|
.where(UsageEventRow.organization_id == organization_id)
|
||||||
|
.order_by(UsageEventRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return list((await self.session.execute(stmt)).scalars())
|
||||||
|
|
||||||
|
async def platform_totals(self) -> list[tuple[str, float, float]]:
|
||||||
|
"""``(organization_id, quantity, total_price)`` across every tenant.
|
||||||
|
|
||||||
|
Platform-owner console only.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
UsageEventRow.organization_id,
|
||||||
|
func.coalesce(func.sum(UsageEventRow.quantity), 0.0),
|
||||||
|
func.coalesce(func.sum(UsageEventRow.total_price), 0.0),
|
||||||
|
)
|
||||||
|
.group_by(UsageEventRow.organization_id)
|
||||||
|
.order_by(func.sum(UsageEventRow.total_price).desc())
|
||||||
|
)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
return [(str(org), float(qty), float(price)) for org, qty, price in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create_trace(self, trace: Trace) -> Trace:
|
||||||
|
self.session.add(
|
||||||
|
TraceRow(
|
||||||
|
id=trace.id, organization_id=trace.organization_id,
|
||||||
|
run_id=trace.run_id, name=trace.name, created_at=trace.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
return trace
|
||||||
|
|
||||||
|
async def add_span(self, span: Span) -> Span:
|
||||||
|
self.session.add(
|
||||||
|
SpanRow(
|
||||||
|
id=span.id, trace_id=span.trace_id, parent_span_id=span.parent_span_id,
|
||||||
|
name=span.name, kind=span.kind, attributes_json=span.attributes_json,
|
||||||
|
started_at=span.started_at, ended_at=span.ended_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
return span
|
||||||
|
|
||||||
|
async def record_error(self, event: ErrorEvent) -> None:
|
||||||
|
self.session.add(
|
||||||
|
ErrorEventRow(
|
||||||
|
id=event.id,
|
||||||
|
organization_id=event.organization_id,
|
||||||
|
run_id=event.run_id,
|
||||||
|
kind=event.kind,
|
||||||
|
message=event.message,
|
||||||
|
stack=event.stack,
|
||||||
|
metadata_json=event.metadata_json,
|
||||||
|
source=event.source,
|
||||||
|
severity=event.severity,
|
||||||
|
error_type=event.error_type,
|
||||||
|
fingerprint=event.fingerprint,
|
||||||
|
status_code=event.status_code,
|
||||||
|
method=event.method,
|
||||||
|
path=event.path,
|
||||||
|
user_id=event.user_id,
|
||||||
|
request_id=event.request_id,
|
||||||
|
ip=event.ip,
|
||||||
|
user_agent=event.user_agent,
|
||||||
|
resolved_at=event.resolved_at,
|
||||||
|
resolved_note=event.resolved_note,
|
||||||
|
created_at=event.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def record_metric(self, metric: QualityMetric) -> None:
|
||||||
|
self.session.add(
|
||||||
|
QualityMetricRow(
|
||||||
|
id=metric.id, organization_id=metric.organization_id, name=metric.name,
|
||||||
|
value=metric.value, dimensions_json=metric.dimensions_json,
|
||||||
|
created_at=metric.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def list_spans(self, *, trace_id: str) -> list[Span]:
|
||||||
|
stmt = select(SpanRow).where(SpanRow.trace_id == trace_id).order_by(SpanRow.started_at)
|
||||||
|
return [
|
||||||
|
Span(
|
||||||
|
id=r.id, trace_id=r.trace_id, parent_span_id=r.parent_span_id, name=r.name,
|
||||||
|
kind=r.kind, attributes_json=r.attributes_json or {},
|
||||||
|
started_at=r.started_at, ended_at=r.ended_at,
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- error console --------------------------------------------------------
|
||||||
|
|
||||||
|
def _error_filters(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
severity: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
kind: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
fingerprint: str | None = None,
|
||||||
|
since: datetime | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""Build the WHERE clauses shared by the list and count queries."""
|
||||||
|
clauses: list = []
|
||||||
|
if severity:
|
||||||
|
clauses.append(ErrorEventRow.severity == severity)
|
||||||
|
if source:
|
||||||
|
clauses.append(ErrorEventRow.source == source)
|
||||||
|
if status == "open":
|
||||||
|
clauses.append(ErrorEventRow.resolved_at.is_(None))
|
||||||
|
elif status == "resolved":
|
||||||
|
clauses.append(ErrorEventRow.resolved_at.is_not(None))
|
||||||
|
if kind:
|
||||||
|
clauses.append(ErrorEventRow.kind == kind)
|
||||||
|
if organization_id:
|
||||||
|
clauses.append(ErrorEventRow.organization_id == organization_id)
|
||||||
|
if fingerprint:
|
||||||
|
clauses.append(ErrorEventRow.fingerprint == fingerprint)
|
||||||
|
if since is not None:
|
||||||
|
clauses.append(ErrorEventRow.created_at >= since)
|
||||||
|
if search:
|
||||||
|
clauses.append(ErrorEventRow.message.ilike(f"%{search}%"))
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
async def list_errors(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
**filters,
|
||||||
|
) -> list[ErrorEvent]:
|
||||||
|
stmt = (
|
||||||
|
select(ErrorEventRow)
|
||||||
|
.where(*self._error_filters(**filters))
|
||||||
|
# Newest first: the failure someone is reacting to is almost always
|
||||||
|
# the most recent one.
|
||||||
|
.order_by(ErrorEventRow.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_error_to_domain(row) for row in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def count_errors(self, **filters) -> int:
|
||||||
|
stmt = select(func.count()).select_from(ErrorEventRow).where(
|
||||||
|
*self._error_filters(**filters)
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
async def get_error(self, error_id: str) -> ErrorEvent | None:
|
||||||
|
row = (
|
||||||
|
await self.session.execute(
|
||||||
|
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return _error_to_domain(row) if row is not None else None
|
||||||
|
|
||||||
|
async def list_error_groups(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 50,
|
||||||
|
since: datetime | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""One row per distinct bug, newest activity first.
|
||||||
|
|
||||||
|
This is the view that makes the console usable: a bug that fired 4 000
|
||||||
|
times is one line with a count, not 4 000 lines. ``open_count`` is what
|
||||||
|
tells you whether anyone has actually dealt with it.
|
||||||
|
"""
|
||||||
|
clauses: list = [ErrorEventRow.fingerprint != ""]
|
||||||
|
if since is not None:
|
||||||
|
clauses.append(ErrorEventRow.created_at >= since)
|
||||||
|
if severity:
|
||||||
|
clauses.append(ErrorEventRow.severity == severity)
|
||||||
|
if source:
|
||||||
|
clauses.append(ErrorEventRow.source == source)
|
||||||
|
if organization_id:
|
||||||
|
clauses.append(ErrorEventRow.organization_id == organization_id)
|
||||||
|
|
||||||
|
open_count = func.sum(
|
||||||
|
case((ErrorEventRow.resolved_at.is_(None), 1), else_=0)
|
||||||
|
).label("open_count")
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
ErrorEventRow.fingerprint,
|
||||||
|
func.count().label("occurrences"),
|
||||||
|
open_count,
|
||||||
|
func.min(ErrorEventRow.created_at).label("first_seen"),
|
||||||
|
func.max(ErrorEventRow.created_at).label("last_seen"),
|
||||||
|
func.max(ErrorEventRow.kind).label("kind"),
|
||||||
|
func.max(ErrorEventRow.error_type).label("error_type"),
|
||||||
|
func.max(ErrorEventRow.severity).label("severity"),
|
||||||
|
func.max(ErrorEventRow.source).label("source"),
|
||||||
|
func.max(ErrorEventRow.path).label("path"),
|
||||||
|
func.max(ErrorEventRow.message).label("sample_message"),
|
||||||
|
)
|
||||||
|
.where(*clauses)
|
||||||
|
.group_by(ErrorEventRow.fingerprint)
|
||||||
|
.order_by(func.max(ErrorEventRow.created_at).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
"fingerprint": row.fingerprint,
|
||||||
|
"occurrences": int(row.occurrences),
|
||||||
|
"open_count": int(row.open_count or 0),
|
||||||
|
"first_seen": row.first_seen,
|
||||||
|
"last_seen": row.last_seen,
|
||||||
|
"kind": row.kind or "",
|
||||||
|
"error_type": row.error_type or "",
|
||||||
|
"severity": row.severity or "",
|
||||||
|
"source": row.source or "",
|
||||||
|
"path": row.path or "",
|
||||||
|
"sample_message": row.sample_message or "",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
if status == "open":
|
||||||
|
return [g for g in groups if g["open_count"] > 0]
|
||||||
|
if status == "resolved":
|
||||||
|
return [g for g in groups if g["open_count"] == 0]
|
||||||
|
return groups
|
||||||
|
|
||||||
|
async def resolve_error(
|
||||||
|
self, error_id: str, *, note: str, resolved_at: datetime
|
||||||
|
) -> ErrorEvent | None:
|
||||||
|
row = (
|
||||||
|
await self.session.execute(
|
||||||
|
select(ErrorEventRow).where(ErrorEventRow.id == error_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
row.resolved_at = resolved_at
|
||||||
|
row.resolved_note = note
|
||||||
|
await self.session.flush()
|
||||||
|
return _error_to_domain(row)
|
||||||
|
|
||||||
|
async def resolve_error_group(
|
||||||
|
self, fingerprint: str, *, note: str, resolved_at: datetime
|
||||||
|
) -> int:
|
||||||
|
"""Resolve every open occurrence of one bug. Returns how many changed."""
|
||||||
|
stmt = select(ErrorEventRow).where(
|
||||||
|
ErrorEventRow.fingerprint == fingerprint,
|
||||||
|
ErrorEventRow.resolved_at.is_(None),
|
||||||
|
)
|
||||||
|
rows = list((await self.session.execute(stmt)).scalars())
|
||||||
|
for row in rows:
|
||||||
|
row.resolved_at = resolved_at
|
||||||
|
row.resolved_note = note
|
||||||
|
await self.session.flush()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def reopen_error_group(self, fingerprint: str) -> int:
|
||||||
|
"""Undo a resolution — for when the bug comes back."""
|
||||||
|
stmt = select(ErrorEventRow).where(
|
||||||
|
ErrorEventRow.fingerprint == fingerprint,
|
||||||
|
ErrorEventRow.resolved_at.is_not(None),
|
||||||
|
)
|
||||||
|
rows = list((await self.session.execute(stmt)).scalars())
|
||||||
|
for row in rows:
|
||||||
|
row.resolved_at = None
|
||||||
|
row.resolved_note = ""
|
||||||
|
await self.session.flush()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def count_open_by_severity(self) -> dict[str, int]:
|
||||||
|
"""Unresolved counts per severity, for the console's header tiles."""
|
||||||
|
stmt = (
|
||||||
|
select(ErrorEventRow.severity, func.count())
|
||||||
|
.where(ErrorEventRow.resolved_at.is_(None))
|
||||||
|
.group_by(ErrorEventRow.severity)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
str(severity): int(count)
|
||||||
|
for severity, count in (await self.session.execute(stmt)).all()
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Repositories for Billing: plans, subscriptions, wallets, invoices, payments.
|
||||||
|
|
||||||
|
The usage ledger lives in ``repositories/audit.py`` because it is shared with the
|
||||||
|
observability layer; billing reads it but never writes to it directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.billing.models import (
|
||||||
|
CreditWallet,
|
||||||
|
Invoice,
|
||||||
|
InvoiceStatus,
|
||||||
|
Payment,
|
||||||
|
PaymentStatus,
|
||||||
|
Plan,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionStatus,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
CreditWalletRow,
|
||||||
|
InvoiceRow,
|
||||||
|
PaymentRow,
|
||||||
|
PlanRow,
|
||||||
|
SubscriptionRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(row: PlanRow) -> Plan:
|
||||||
|
return Plan(
|
||||||
|
id=row.id, name=row.name, monthly_price=row.monthly_price,
|
||||||
|
included_credit=row.included_credit, features_json=dict(row.features_json or {}),
|
||||||
|
is_active=row.is_active, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription(row: SubscriptionRow) -> Subscription:
|
||||||
|
return Subscription(
|
||||||
|
id=row.id, organization_id=row.organization_id, plan_id=row.plan_id,
|
||||||
|
status=SubscriptionStatus(row.status),
|
||||||
|
external_subscription_id=row.external_subscription_id,
|
||||||
|
current_period_end=row.current_period_end, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wallet(row: CreditWalletRow) -> CreditWallet:
|
||||||
|
return CreditWallet(
|
||||||
|
id=row.id, organization_id=row.organization_id, balance=row.balance,
|
||||||
|
currency=row.currency, low_balance_threshold=row.low_balance_threshold,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice(row: InvoiceRow) -> Invoice:
|
||||||
|
return Invoice(
|
||||||
|
id=row.id, organization_id=row.organization_id, amount=row.amount,
|
||||||
|
currency=row.currency, status=InvoiceStatus(row.status),
|
||||||
|
external_invoice_id=row.external_invoice_id, period_start=row.period_start,
|
||||||
|
period_end=row.period_end, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payment(row: PaymentRow) -> Payment:
|
||||||
|
return Payment(
|
||||||
|
id=row.id, organization_id=row.organization_id, amount=row.amount,
|
||||||
|
status=PaymentStatus(row.status), provider=row.provider,
|
||||||
|
external_payment_id=row.external_payment_id, invoice_id=row.invoice_id,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self) -> list[Plan]:
|
||||||
|
stmt = select(PlanRow).order_by(PlanRow.monthly_price)
|
||||||
|
return [_plan(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, plan_id: str) -> Plan | None:
|
||||||
|
row = await self.session.get(PlanRow, plan_id)
|
||||||
|
return _plan(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str) -> Subscription | None:
|
||||||
|
stmt = select(SubscriptionRow).where(
|
||||||
|
SubscriptionRow.organization_id == organization_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalars().first()
|
||||||
|
return _subscription(row) if row else None
|
||||||
|
|
||||||
|
async def upsert(self, subscription: Subscription) -> Subscription:
|
||||||
|
existing = await self.get_for_org(subscription.organization_id)
|
||||||
|
if existing:
|
||||||
|
row = await self.session.get(SubscriptionRow, existing.id)
|
||||||
|
assert row is not None
|
||||||
|
row.plan_id = subscription.plan_id
|
||||||
|
row.status = str(subscription.status)
|
||||||
|
row.external_subscription_id = subscription.external_subscription_id
|
||||||
|
row.current_period_end = subscription.current_period_end
|
||||||
|
await self.session.flush()
|
||||||
|
return _subscription(row)
|
||||||
|
|
||||||
|
row = SubscriptionRow(
|
||||||
|
id=subscription.id, organization_id=subscription.organization_id,
|
||||||
|
plan_id=subscription.plan_id, status=str(subscription.status),
|
||||||
|
external_subscription_id=subscription.external_subscription_id,
|
||||||
|
current_period_end=subscription.current_period_end,
|
||||||
|
created_at=subscription.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _subscription(row)
|
||||||
|
|
||||||
|
|
||||||
|
class CreditWalletRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str) -> CreditWallet | None:
|
||||||
|
stmt = select(CreditWalletRow).where(
|
||||||
|
CreditWalletRow.organization_id == organization_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _wallet(row) if row else None
|
||||||
|
|
||||||
|
async def ensure(self, organization_id: str) -> CreditWallet:
|
||||||
|
"""Return the org's wallet, creating a zero-balance one if absent."""
|
||||||
|
existing = await self.get_for_org(organization_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
row = CreditWalletRow(organization_id=organization_id, balance=0.0, currency="USD")
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _wallet(row)
|
||||||
|
|
||||||
|
async def adjust(self, *, organization_id: str, delta: float) -> CreditWallet:
|
||||||
|
"""Add (or subtract) credit. Callers must validate the delta's sign."""
|
||||||
|
wallet = await self.ensure(organization_id)
|
||||||
|
row = await self.session.get(CreditWalletRow, wallet.id)
|
||||||
|
assert row is not None
|
||||||
|
row.balance = float(row.balance) + float(delta)
|
||||||
|
await self.session.flush()
|
||||||
|
return _wallet(row)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_org(self, organization_id: str) -> list[Invoice]:
|
||||||
|
stmt = (
|
||||||
|
select(InvoiceRow)
|
||||||
|
.where(InvoiceRow.organization_id == organization_id)
|
||||||
|
.order_by(InvoiceRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_invoice(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, invoice: Invoice) -> Invoice:
|
||||||
|
row = InvoiceRow(
|
||||||
|
id=invoice.id, organization_id=invoice.organization_id,
|
||||||
|
amount=invoice.amount, currency=invoice.currency,
|
||||||
|
status=str(invoice.status),
|
||||||
|
external_invoice_id=invoice.external_invoice_id,
|
||||||
|
period_start=invoice.period_start, period_end=invoice.period_end,
|
||||||
|
created_at=invoice.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _invoice(row)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_org(self, organization_id: str) -> list[Payment]:
|
||||||
|
stmt = (
|
||||||
|
select(PaymentRow)
|
||||||
|
.where(PaymentRow.organization_id == organization_id)
|
||||||
|
.order_by(PaymentRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_payment(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, payment: Payment) -> Payment:
|
||||||
|
row = PaymentRow(
|
||||||
|
id=payment.id, organization_id=payment.organization_id,
|
||||||
|
amount=payment.amount, status=str(payment.status),
|
||||||
|
provider=payment.provider, external_payment_id=payment.external_payment_id,
|
||||||
|
invoice_id=payment.invoice_id, created_at=payment.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _payment(row)
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self, payment_id: str, status: PaymentStatus, *, external_id: str | None = None
|
||||||
|
) -> Payment | None:
|
||||||
|
row = await self.session.get(PaymentRow, payment_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
row.status = str(status)
|
||||||
|
if external_id is not None:
|
||||||
|
row.external_payment_id = external_id
|
||||||
|
await self.session.flush()
|
||||||
|
return _payment(row)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Repositories for Identity & Access.
|
||||||
|
|
||||||
|
Each repository maps between the ORM rows and the pure domain dataclasses, so
|
||||||
|
the domain layer never sees SQLAlchemy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.identity.models import (
|
||||||
|
Invite,
|
||||||
|
Membership,
|
||||||
|
MembershipStatus,
|
||||||
|
Organization,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
InviteRow,
|
||||||
|
MembershipRow,
|
||||||
|
OrganizationRow,
|
||||||
|
UserRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_to_domain(row: UserRow) -> User:
|
||||||
|
return User(
|
||||||
|
id=row.id,
|
||||||
|
email=row.email,
|
||||||
|
password_hash=row.password_hash,
|
||||||
|
name=row.name,
|
||||||
|
avatar_url=row.avatar_url,
|
||||||
|
is_platform_owner=row.is_platform_owner,
|
||||||
|
is_active=row.is_active,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _org_to_domain(row: OrganizationRow) -> Organization:
|
||||||
|
return Organization(
|
||||||
|
id=row.id, name=row.name, slug=row.slug,
|
||||||
|
owner_user_id=row.owner_user_id, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_to_domain(row: MembershipRow) -> Membership:
|
||||||
|
return Membership(
|
||||||
|
id=row.id,
|
||||||
|
organization_id=row.organization_id,
|
||||||
|
user_id=row.user_id,
|
||||||
|
role=Role(row.role),
|
||||||
|
status=MembershipStatus(row.status),
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invite_to_domain(row: InviteRow) -> Invite:
|
||||||
|
return Invite(
|
||||||
|
id=row.id,
|
||||||
|
organization_id=row.organization_id,
|
||||||
|
email=row.email,
|
||||||
|
role=Role(row.role),
|
||||||
|
token_hash=row.token_hash,
|
||||||
|
invited_by_user_id=row.invited_by_user_id,
|
||||||
|
expires_at=row.expires_at,
|
||||||
|
accepted_at=row.accepted_at,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, user_id: str) -> User | None:
|
||||||
|
row = await self.session.get(UserRow, user_id)
|
||||||
|
return _user_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_email(self, email: str) -> User | None:
|
||||||
|
stmt = select(UserRow).where(func.lower(UserRow.email) == email.strip().lower())
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _user_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, user: User) -> User:
|
||||||
|
row = UserRow(
|
||||||
|
id=user.id,
|
||||||
|
email=user.normalized_email(),
|
||||||
|
password_hash=user.password_hash,
|
||||||
|
name=user.name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
is_platform_owner=user.is_platform_owner,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
updated_at=user.updated_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _user_to_domain(row)
|
||||||
|
|
||||||
|
async def update_password(self, user_id: str, password_hash: str) -> None:
|
||||||
|
row = await self.session.get(UserRow, user_id)
|
||||||
|
if row:
|
||||||
|
row.password_hash = password_hash
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
return int((await self.session.execute(select(func.count(UserRow.id)))).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, org_id: str) -> Organization | None:
|
||||||
|
row = await self.session.get(OrganizationRow, org_id)
|
||||||
|
return _org_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_slug(self, slug: str) -> Organization | None:
|
||||||
|
stmt = select(OrganizationRow).where(OrganizationRow.slug == slug)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _org_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def list_all_slugs(self) -> set[str]:
|
||||||
|
rows = (await self.session.execute(select(OrganizationRow.slug))).scalars().all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
async def create(self, org: Organization) -> Organization:
|
||||||
|
row = OrganizationRow(
|
||||||
|
id=org.id, name=org.name, slug=org.slug,
|
||||||
|
owner_user_id=org.owner_user_id, created_at=org.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _org_to_domain(row)
|
||||||
|
|
||||||
|
async def list_for_user(self, user_id: str) -> list[tuple[Organization, Role]]:
|
||||||
|
stmt = (
|
||||||
|
select(OrganizationRow, MembershipRow.role)
|
||||||
|
.join(MembershipRow, MembershipRow.organization_id == OrganizationRow.id)
|
||||||
|
.where(MembershipRow.user_id == user_id, MembershipRow.status == "active")
|
||||||
|
.order_by(OrganizationRow.created_at)
|
||||||
|
)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
return [(_org_to_domain(o), Role(r)) for o, r in rows]
|
||||||
|
|
||||||
|
async def list_all(self) -> list[Organization]:
|
||||||
|
"""Every organization. Platform-owner console only — never expose this
|
||||||
|
through a tenant-scoped endpoint."""
|
||||||
|
stmt = select(OrganizationRow).order_by(OrganizationRow.created_at)
|
||||||
|
return [_org_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def delete(self, org_id: str) -> None:
|
||||||
|
row = await self.session.get(OrganizationRow, org_id)
|
||||||
|
if row:
|
||||||
|
await self.session.delete(row)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
return int(
|
||||||
|
(await self.session.execute(select(func.count(OrganizationRow.id)))).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, org_id: str, user_id: str) -> Membership | None:
|
||||||
|
stmt = select(MembershipRow).where(
|
||||||
|
MembershipRow.organization_id == org_id, MembershipRow.user_id == user_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _membership_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_id(self, membership_id: str) -> Membership | None:
|
||||||
|
row = await self.session.get(MembershipRow, membership_id)
|
||||||
|
return _membership_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_org(self, org_id: str) -> list[Membership]:
|
||||||
|
stmt = (
|
||||||
|
select(MembershipRow)
|
||||||
|
.where(MembershipRow.organization_id == org_id)
|
||||||
|
.order_by(MembershipRow.created_at)
|
||||||
|
)
|
||||||
|
return [_membership_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def add(self, membership: Membership) -> Membership:
|
||||||
|
row = MembershipRow(
|
||||||
|
id=membership.id,
|
||||||
|
organization_id=membership.organization_id,
|
||||||
|
user_id=membership.user_id,
|
||||||
|
role=membership.role.value,
|
||||||
|
status=membership.status.value,
|
||||||
|
created_at=membership.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _membership_to_domain(row)
|
||||||
|
|
||||||
|
async def set_role(self, membership_id: str, role: Role) -> None:
|
||||||
|
row = await self.session.get(MembershipRow, membership_id)
|
||||||
|
if row:
|
||||||
|
row.role = role.value
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def set_status(self, membership_id: str, status: MembershipStatus) -> None:
|
||||||
|
row = await self.session.get(MembershipRow, membership_id)
|
||||||
|
if row:
|
||||||
|
row.status = status.value
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def remove(self, membership_id: str) -> None:
|
||||||
|
row = await self.session.get(MembershipRow, membership_id)
|
||||||
|
if row:
|
||||||
|
await self.session.delete(row)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def count_owners(self, org_id: str) -> int:
|
||||||
|
stmt = select(func.count(MembershipRow.id)).where(
|
||||||
|
MembershipRow.organization_id == org_id,
|
||||||
|
MembershipRow.role == Role.OWNER.value,
|
||||||
|
MembershipRow.status == MembershipStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
|
||||||
|
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _invite_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def list_pending(self, org_id: str) -> list[Invite]:
|
||||||
|
stmt = (
|
||||||
|
select(InviteRow)
|
||||||
|
.where(InviteRow.organization_id == org_id, InviteRow.accepted_at.is_(None))
|
||||||
|
.order_by(InviteRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_invite_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, invite: Invite) -> Invite:
|
||||||
|
row = InviteRow(
|
||||||
|
id=invite.id,
|
||||||
|
organization_id=invite.organization_id,
|
||||||
|
email=invite.email.strip().lower(),
|
||||||
|
role=invite.role.value,
|
||||||
|
token_hash=invite.token_hash,
|
||||||
|
invited_by_user_id=invite.invited_by_user_id,
|
||||||
|
expires_at=invite.expires_at,
|
||||||
|
accepted_at=invite.accepted_at,
|
||||||
|
created_at=invite.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _invite_to_domain(row)
|
||||||
|
|
||||||
|
async def mark_accepted(self, invite_id: str, when: datetime) -> None:
|
||||||
|
row = await self.session.get(InviteRow, invite_id)
|
||||||
|
if row:
|
||||||
|
row.accepted_at = when
|
||||||
|
await self.session.flush()
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Repositories for the Knowledge Base.
|
||||||
|
|
||||||
|
Retrieval is tenant-scoped by construction: every search joins
|
||||||
|
``document_chunks -> documents -> knowledge_sources`` and filters on
|
||||||
|
``knowledge_sources.organization_id``. There is no search entry point that takes
|
||||||
|
a chunk id directly, so a caller cannot reach across tenants.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.knowledge.models import (
|
||||||
|
Document,
|
||||||
|
DocumentChunk,
|
||||||
|
EmbeddingJob,
|
||||||
|
EmbeddingJobStatus,
|
||||||
|
KnowledgeSource,
|
||||||
|
RetrievalPolicy,
|
||||||
|
SourceKind,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
DocumentChunkRow,
|
||||||
|
DocumentRow,
|
||||||
|
EmbeddingJobRow,
|
||||||
|
KnowledgeSourceRow,
|
||||||
|
RetrievalPolicyRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source(row: KnowledgeSourceRow) -> KnowledgeSource:
|
||||||
|
return KnowledgeSource(
|
||||||
|
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||||
|
name=row.name, kind=SourceKind(row.kind), uri=row.uri, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document(row: DocumentRow) -> Document:
|
||||||
|
return Document(
|
||||||
|
id=row.id, source_id=row.source_id, title=row.title, content=row.content,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSourceRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, source_id: str) -> KnowledgeSource | None:
|
||||||
|
row = await self.session.get(KnowledgeSourceRow, source_id)
|
||||||
|
return _source(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, source_id: str) -> KnowledgeSource | None:
|
||||||
|
stmt = select(KnowledgeSourceRow).where(
|
||||||
|
KnowledgeSourceRow.id == source_id,
|
||||||
|
KnowledgeSourceRow.organization_id == organization_id,
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _source(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[KnowledgeSource]:
|
||||||
|
stmt = (
|
||||||
|
select(KnowledgeSourceRow)
|
||||||
|
.where(KnowledgeSourceRow.organization_id == organization_id)
|
||||||
|
.order_by(KnowledgeSourceRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_source(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, source: KnowledgeSource) -> KnowledgeSource:
|
||||||
|
row = KnowledgeSourceRow(
|
||||||
|
id=source.id, organization_id=source.organization_id,
|
||||||
|
project_id=source.project_id, name=source.name, kind=str(source.kind),
|
||||||
|
uri=source.uri, created_at=source.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _source(row)
|
||||||
|
|
||||||
|
async def delete(self, source_id: str) -> None:
|
||||||
|
row = await self.session.get(KnowledgeSourceRow, source_id)
|
||||||
|
if row:
|
||||||
|
await self.session.delete(row)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_source(self, source_id: str) -> list[Document]:
|
||||||
|
stmt = (
|
||||||
|
select(DocumentRow)
|
||||||
|
.where(DocumentRow.source_id == source_id)
|
||||||
|
.order_by(DocumentRow.created_at)
|
||||||
|
)
|
||||||
|
return [_document(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, document: Document) -> Document:
|
||||||
|
row = DocumentRow(
|
||||||
|
id=document.id, source_id=document.source_id, title=document.title,
|
||||||
|
content=document.content, created_at=document.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _document(row)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentChunkRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create_many(self, chunks: list[DocumentChunk]) -> int:
|
||||||
|
for chunk in chunks:
|
||||||
|
self.session.add(
|
||||||
|
DocumentChunkRow(
|
||||||
|
id=chunk.id, document_id=chunk.document_id, index=chunk.index,
|
||||||
|
content=chunk.content, embedding_json=list(chunk.embedding_json),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
return len(chunks)
|
||||||
|
|
||||||
|
async def count_for_source(self, source_id: str) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(DocumentChunkRow)
|
||||||
|
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||||
|
.where(DocumentRow.source_id == source_id)
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
async def count_for_sources(self, source_ids: list[str]) -> dict[str, int]:
|
||||||
|
"""Chunk counts for many sources in one grouped query.
|
||||||
|
|
||||||
|
Listing sources needs a count per row; doing that one source at a time
|
||||||
|
is an N+1. ``GROUP BY`` collapses it into a single round trip, and
|
||||||
|
sources with no chunks are simply absent from the result.
|
||||||
|
"""
|
||||||
|
if not source_ids:
|
||||||
|
return {}
|
||||||
|
stmt = (
|
||||||
|
select(DocumentRow.source_id, func.count(DocumentChunkRow.id))
|
||||||
|
.select_from(DocumentChunkRow)
|
||||||
|
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||||
|
.where(DocumentRow.source_id.in_(source_ids))
|
||||||
|
.group_by(DocumentRow.source_id)
|
||||||
|
)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
return {str(source_id): int(count) for source_id, count in rows}
|
||||||
|
|
||||||
|
async def keyword_search(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
query: str,
|
||||||
|
project_id: str | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""MVP retrieval: case-insensitive substring match over chunk text.
|
||||||
|
|
||||||
|
A real deployment swaps this for a vector search through
|
||||||
|
``VectorStorePort``; the tenant scoping stays identical.
|
||||||
|
"""
|
||||||
|
needle = f"%{query.strip().lower()}%"
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
DocumentChunkRow.id,
|
||||||
|
DocumentChunkRow.content,
|
||||||
|
DocumentChunkRow.index,
|
||||||
|
DocumentRow.title,
|
||||||
|
KnowledgeSourceRow.id,
|
||||||
|
KnowledgeSourceRow.name,
|
||||||
|
KnowledgeSourceRow.project_id,
|
||||||
|
)
|
||||||
|
.join(DocumentRow, DocumentRow.id == DocumentChunkRow.document_id)
|
||||||
|
.join(KnowledgeSourceRow, KnowledgeSourceRow.id == DocumentRow.source_id)
|
||||||
|
.where(
|
||||||
|
KnowledgeSourceRow.organization_id == organization_id,
|
||||||
|
func.lower(DocumentChunkRow.content).like(needle),
|
||||||
|
)
|
||||||
|
.order_by(DocumentChunkRow.document_id, DocumentChunkRow.index)
|
||||||
|
.limit(min(limit, 50))
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
# An org-wide source (project_id is NULL) is visible to every project.
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
KnowledgeSourceRow.project_id.is_(None),
|
||||||
|
KnowledgeSourceRow.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"chunk_id": chunk_id,
|
||||||
|
"content": content,
|
||||||
|
"index": index,
|
||||||
|
"document_title": title,
|
||||||
|
"source_id": source_id,
|
||||||
|
"source_name": source_name,
|
||||||
|
"project_id": source_project_id,
|
||||||
|
}
|
||||||
|
for chunk_id, content, index, title, source_id, source_name, source_project_id in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingJobRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create(self, job: EmbeddingJob) -> EmbeddingJob:
|
||||||
|
row = EmbeddingJobRow(
|
||||||
|
id=job.id, source_id=job.source_id, status=str(job.status),
|
||||||
|
error=job.error, created_at=job.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return EmbeddingJob(
|
||||||
|
id=row.id, source_id=row.source_id,
|
||||||
|
status=EmbeddingJobStatus(row.status), error=row.error,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def latest_for_source(self, source_id: str) -> EmbeddingJob | None:
|
||||||
|
stmt = (
|
||||||
|
select(EmbeddingJobRow)
|
||||||
|
.where(EmbeddingJobRow.source_id == source_id)
|
||||||
|
.order_by(EmbeddingJobRow.created_at.desc())
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalars().first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return EmbeddingJob(
|
||||||
|
id=row.id, source_id=row.source_id,
|
||||||
|
status=EmbeddingJobStatus(row.status), error=row.error,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self, job_id: str, status: EmbeddingJobStatus, *, error: str | None = None
|
||||||
|
) -> None:
|
||||||
|
row = await self.session.get(EmbeddingJobRow, job_id)
|
||||||
|
if row:
|
||||||
|
row.status = str(status)
|
||||||
|
if error is not None:
|
||||||
|
row.error = error
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalPolicyRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_org(self, organization_id: str) -> list[RetrievalPolicy]:
|
||||||
|
stmt = select(RetrievalPolicyRow).where(
|
||||||
|
RetrievalPolicyRow.organization_id == organization_id
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
RetrievalPolicy(
|
||||||
|
id=r.id, organization_id=r.organization_id, name=r.name,
|
||||||
|
scope_json=dict(r.scope_json or {}), created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create(self, policy: RetrievalPolicy) -> RetrievalPolicy:
|
||||||
|
row = RetrievalPolicyRow(
|
||||||
|
id=policy.id, organization_id=policy.organization_id, name=policy.name,
|
||||||
|
scope_json=dict(policy.scope_json), created_at=policy.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return RetrievalPolicy(
|
||||||
|
id=row.id, organization_id=row.organization_id, name=row.name,
|
||||||
|
scope_json=dict(row.scope_json or {}), created_at=row.created_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Repositories for Projects, their Git repositories, credentials and preview
|
||||||
|
domains."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.projects.models import (
|
||||||
|
Project,
|
||||||
|
ProjectGitCredential,
|
||||||
|
ProjectGitRepo,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
PreviewDomainRow,
|
||||||
|
PreviewPortRow,
|
||||||
|
ProjectGitCredentialRow,
|
||||||
|
ProjectRepositoryRow,
|
||||||
|
ProjectRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_to_domain(row: ProjectRow) -> Project:
|
||||||
|
return Project(
|
||||||
|
id=row.id, organization_id=row.organization_id, name=row.name, slug=row.slug,
|
||||||
|
description=row.description, created_by_user_id=row.created_by_user_id,
|
||||||
|
default_branch=row.default_branch, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
|
||||||
|
return ProjectGitRepo(
|
||||||
|
id=row.id, project_id=row.project_id, provider=row.provider, owner=row.owner,
|
||||||
|
repo_name=row.repo_name, clone_url=row.clone_url, web_url=row.web_url,
|
||||||
|
default_branch=row.default_branch, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectGitRepoRepository:
|
||||||
|
"""Persistence for the Git repository attached to a project."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_for_project(self, project_id: str) -> ProjectGitRepo | None:
|
||||||
|
stmt = select(ProjectRepositoryRow).where(
|
||||||
|
ProjectRepositoryRow.project_id == project_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _repo_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def upsert(self, repo: ProjectGitRepo) -> ProjectGitRepo:
|
||||||
|
existing = await self.get_for_project(repo.project_id)
|
||||||
|
if existing:
|
||||||
|
row = await self.session.get(ProjectRepositoryRow, existing.id)
|
||||||
|
assert row is not None
|
||||||
|
row.provider = repo.provider
|
||||||
|
row.owner = repo.owner
|
||||||
|
row.repo_name = repo.repo_name
|
||||||
|
row.clone_url = repo.clone_url
|
||||||
|
row.web_url = repo.web_url
|
||||||
|
row.default_branch = repo.default_branch
|
||||||
|
await self.session.flush()
|
||||||
|
return _repo_to_domain(row)
|
||||||
|
|
||||||
|
row = ProjectRepositoryRow(
|
||||||
|
id=repo.id, project_id=repo.project_id, provider=repo.provider,
|
||||||
|
owner=repo.owner, repo_name=repo.repo_name, clone_url=repo.clone_url,
|
||||||
|
web_url=repo.web_url, default_branch=repo.default_branch,
|
||||||
|
created_at=repo.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _repo_to_domain(row)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectGitCredentialRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_for_project(self, project_id: str) -> ProjectGitCredential | None:
|
||||||
|
stmt = select(ProjectGitCredentialRow).where(
|
||||||
|
ProjectGitCredentialRow.project_id == project_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return ProjectGitCredential(
|
||||||
|
id=row.id, project_id=row.project_id, username=row.username,
|
||||||
|
encrypted_password=row.encrypted_password, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upsert(self, credential: ProjectGitCredential) -> ProjectGitCredential:
|
||||||
|
existing = await self.get_for_project(credential.project_id)
|
||||||
|
if existing:
|
||||||
|
row = await self.session.get(ProjectGitCredentialRow, existing.id)
|
||||||
|
assert row is not None
|
||||||
|
row.username = credential.username
|
||||||
|
row.encrypted_password = credential.encrypted_password
|
||||||
|
await self.session.flush()
|
||||||
|
return ProjectGitCredential(
|
||||||
|
id=row.id, project_id=row.project_id, username=row.username,
|
||||||
|
encrypted_password=row.encrypted_password, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = ProjectGitCredentialRow(
|
||||||
|
id=credential.id, project_id=credential.project_id,
|
||||||
|
username=credential.username,
|
||||||
|
encrypted_password=credential.encrypted_password,
|
||||||
|
created_at=credential.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return credential
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, project_id: str) -> Project | None:
|
||||||
|
row = await self.session.get(ProjectRow, project_id)
|
||||||
|
return _project_to_domain(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Project]:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectRow)
|
||||||
|
.where(ProjectRow.organization_id == organization_id)
|
||||||
|
.order_by(ProjectRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_project_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_slugs(self, organization_id: str) -> set[str]:
|
||||||
|
stmt = select(ProjectRow.slug).where(ProjectRow.organization_id == organization_id)
|
||||||
|
return set((await self.session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
async def create(self, project: Project) -> Project:
|
||||||
|
row = ProjectRow(
|
||||||
|
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||||
|
slug=project.slug, description=project.description,
|
||||||
|
created_by_user_id=project.created_by_user_id,
|
||||||
|
default_branch=project.default_branch, created_at=project.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _project_to_domain(row)
|
||||||
|
|
||||||
|
async def delete(self, project_id: str) -> None:
|
||||||
|
row = await self.session.get(ProjectRow, project_id)
|
||||||
|
if row:
|
||||||
|
await self.session.delete(row)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewDomainRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
# -- ports ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_port(self, project_id: str, service_name: str) -> int | None:
|
||||||
|
stmt = select(PreviewPortRow.port).where(
|
||||||
|
PreviewPortRow.project_id == project_id,
|
||||||
|
PreviewPortRow.service_name == service_name,
|
||||||
|
)
|
||||||
|
return (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_used_ports(self, *, start: int, end: int) -> set[int]:
|
||||||
|
stmt = select(PreviewPortRow.port).where(
|
||||||
|
PreviewPortRow.port.between(start, end)
|
||||||
|
)
|
||||||
|
return set((await self.session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
async def add_port(
|
||||||
|
self, *, organization_id: str, project_id: str, service_name: str, port: int
|
||||||
|
) -> None:
|
||||||
|
self.session.add(
|
||||||
|
PreviewPortRow(
|
||||||
|
organization_id=organization_id, project_id=project_id,
|
||||||
|
service_name=service_name, port=port, status="active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
# -- domains -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_by_host(self, host: str) -> dict | None:
|
||||||
|
stmt = select(PreviewDomainRow).where(PreviewDomainRow.host == host)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return self._row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
async def get(self, domain_id: str) -> dict | None:
|
||||||
|
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||||
|
return self._row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_project(self, project_id: str) -> list[dict]:
|
||||||
|
stmt = (
|
||||||
|
select(PreviewDomainRow)
|
||||||
|
.where(PreviewDomainRow.project_id == project_id)
|
||||||
|
.order_by(PreviewDomainRow.created_at)
|
||||||
|
)
|
||||||
|
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def count_for_project(self, project_id: str) -> int:
|
||||||
|
return len(await self.list_for_project(project_id))
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
project_id: str,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
created_by_user_id: str | None,
|
||||||
|
) -> dict:
|
||||||
|
row = PreviewDomainRow(
|
||||||
|
organization_id=organization_id, project_id=project_id, host=host,
|
||||||
|
port=port, status="active", protected=False,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return self._row_to_dict(row)
|
||||||
|
|
||||||
|
async def set_status(self, domain_id: str, status: str) -> None:
|
||||||
|
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||||
|
if row:
|
||||||
|
row.status = status
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def delete(self, domain_id: str) -> None:
|
||||||
|
row = await self.session.get(PreviewDomainRow, domain_id)
|
||||||
|
if row:
|
||||||
|
await self.session.delete(row)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_dict(row: PreviewDomainRow) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"organization_id": row.organization_id,
|
||||||
|
"project_id": row.project_id,
|
||||||
|
"host": row.host,
|
||||||
|
"port": row.port,
|
||||||
|
"status": row.status,
|
||||||
|
"protected": row.protected,
|
||||||
|
"created_by_user_id": row.created_by_user_id,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
"""Repositories for the Sandbox Gateway.
|
||||||
|
|
||||||
|
Every tenant-scoped read joins back to ``workspaces.organization_id`` so a
|
||||||
|
workspace or session id from another tenant resolves to ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.base import utcnow
|
||||||
|
from app.domain.sandbox.models import (
|
||||||
|
CommandRun,
|
||||||
|
NetworkPolicy,
|
||||||
|
PreviewUrl,
|
||||||
|
SandboxProviderKind,
|
||||||
|
SandboxResourceLimit,
|
||||||
|
SandboxSession,
|
||||||
|
SandboxTemplate,
|
||||||
|
SessionStatus,
|
||||||
|
Snapshot,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceStatus,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
CommandRunRow,
|
||||||
|
PreviewUrlRow,
|
||||||
|
SandboxResourceLimitRow,
|
||||||
|
SandboxSessionRow,
|
||||||
|
SandboxTemplateRow,
|
||||||
|
SnapshotRow,
|
||||||
|
WorkspaceRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template(row: SandboxTemplateRow) -> SandboxTemplate:
|
||||||
|
return SandboxTemplate(
|
||||||
|
id=row.id, name=row.name, provider=row.provider, image=row.image,
|
||||||
|
description=row.description, default_cpu=row.default_cpu,
|
||||||
|
default_memory_mb=row.default_memory_mb, default_disk_mb=row.default_disk_mb,
|
||||||
|
is_system=row.is_system, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace(row: WorkspaceRow) -> Workspace:
|
||||||
|
return Workspace(
|
||||||
|
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||||
|
template_id=row.template_id, provider=row.provider,
|
||||||
|
external_workspace_id=row.external_workspace_id,
|
||||||
|
status=WorkspaceStatus(row.status), created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(row: SandboxSessionRow) -> SandboxSession:
|
||||||
|
return SandboxSession(
|
||||||
|
id=row.id, workspace_id=row.workspace_id,
|
||||||
|
external_session_id=row.external_session_id,
|
||||||
|
status=SessionStatus(row.status), started_at=row.started_at,
|
||||||
|
stopped_at=row.stopped_at, last_active_at=row.last_active_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxTemplateRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self) -> list[SandboxTemplate]:
|
||||||
|
stmt = select(SandboxTemplateRow).order_by(SandboxTemplateRow.name)
|
||||||
|
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, template_id: str) -> SandboxTemplate | None:
|
||||||
|
row = await self.session.get(SandboxTemplateRow, template_id)
|
||||||
|
return _template(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str) -> SandboxTemplate | None:
|
||||||
|
stmt = select(SandboxTemplateRow).where(SandboxTemplateRow.name == name)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _template(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, template: SandboxTemplate) -> SandboxTemplate:
|
||||||
|
row = SandboxTemplateRow(
|
||||||
|
id=template.id, name=template.name, provider=str(template.provider),
|
||||||
|
image=template.image, description=template.description,
|
||||||
|
default_cpu=template.default_cpu,
|
||||||
|
default_memory_mb=template.default_memory_mb,
|
||||||
|
default_disk_mb=template.default_disk_mb, is_system=template.is_system,
|
||||||
|
created_at=template.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _template(row)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, workspace_id: str) -> Workspace | None:
|
||||||
|
"""Unscoped lookup. Platform components only — tenant paths use
|
||||||
|
:meth:`get_for_org` so a foreign id resolves to ``None``."""
|
||||||
|
row = await self.session.get(WorkspaceRow, workspace_id)
|
||||||
|
return _workspace(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, workspace_id: str) -> Workspace | None:
|
||||||
|
stmt = select(WorkspaceRow).where(
|
||||||
|
WorkspaceRow.id == workspace_id,
|
||||||
|
WorkspaceRow.organization_id == organization_id,
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _workspace(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_project(
|
||||||
|
self, project_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Workspace]:
|
||||||
|
stmt = (
|
||||||
|
select(WorkspaceRow)
|
||||||
|
.where(WorkspaceRow.project_id == project_id)
|
||||||
|
.order_by(WorkspaceRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_workspace(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, workspace: Workspace) -> Workspace:
|
||||||
|
row = WorkspaceRow(
|
||||||
|
id=workspace.id, organization_id=workspace.organization_id,
|
||||||
|
project_id=workspace.project_id, template_id=workspace.template_id,
|
||||||
|
provider=str(workspace.provider),
|
||||||
|
external_workspace_id=workspace.external_workspace_id,
|
||||||
|
status=str(workspace.status), created_at=workspace.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _workspace(row)
|
||||||
|
|
||||||
|
async def set_status(self, workspace_id: str, status: WorkspaceStatus) -> None:
|
||||||
|
row = await self.session.get(WorkspaceRow, workspace_id)
|
||||||
|
if row:
|
||||||
|
row.status = str(status)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxSessionRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, session_id: str) -> SandboxSession | None:
|
||||||
|
row = await self.session.get(SandboxSessionRow, session_id)
|
||||||
|
return _session(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, session_id: str) -> SandboxSession | None:
|
||||||
|
"""Resolve a session through its workspace, which carries the tenant."""
|
||||||
|
stmt = (
|
||||||
|
select(SandboxSessionRow)
|
||||||
|
.join(WorkspaceRow, WorkspaceRow.id == SandboxSessionRow.workspace_id)
|
||||||
|
.where(
|
||||||
|
SandboxSessionRow.id == session_id,
|
||||||
|
WorkspaceRow.organization_id == organization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _session(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_workspace(self, workspace_id: str) -> list[SandboxSession]:
|
||||||
|
stmt = (
|
||||||
|
select(SandboxSessionRow)
|
||||||
|
.where(SandboxSessionRow.workspace_id == workspace_id)
|
||||||
|
.order_by(SandboxSessionRow.started_at.desc())
|
||||||
|
)
|
||||||
|
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_stale_active(
|
||||||
|
self, *, idle_before: datetime, limit: int = 200
|
||||||
|
) -> list[SandboxSession]:
|
||||||
|
"""Active sessions untouched since ``idle_before``.
|
||||||
|
|
||||||
|
The gateway reaper's query. Only ``starting``/``running``/``paused``
|
||||||
|
count as active — a stopped session is already gone and must not be
|
||||||
|
stopped twice.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(SandboxSessionRow)
|
||||||
|
.where(
|
||||||
|
SandboxSessionRow.status.in_(
|
||||||
|
[
|
||||||
|
str(SessionStatus.STARTING),
|
||||||
|
str(SessionStatus.RUNNING),
|
||||||
|
str(SessionStatus.PAUSED),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
SandboxSessionRow.last_active_at < idle_before,
|
||||||
|
)
|
||||||
|
.order_by(SandboxSessionRow.last_active_at.asc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
)
|
||||||
|
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
|
||||||
|
row = SandboxSessionRow(
|
||||||
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||||
|
external_session_id=sandbox_session.external_session_id,
|
||||||
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||||
|
stopped_at=sandbox_session.stopped_at,
|
||||||
|
last_active_at=sandbox_session.last_active_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _session(row)
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self, session_id: str, status: SessionStatus, *, stopped: bool = False
|
||||||
|
) -> None:
|
||||||
|
row = await self.session.get(SandboxSessionRow, session_id)
|
||||||
|
if row:
|
||||||
|
row.status = str(status)
|
||||||
|
row.last_active_at = utcnow()
|
||||||
|
if stopped:
|
||||||
|
row.stopped_at = utcnow()
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_session(self, session_id: str) -> list[CommandRun]:
|
||||||
|
stmt = (
|
||||||
|
select(CommandRunRow)
|
||||||
|
.where(CommandRunRow.session_id == session_id)
|
||||||
|
.order_by(CommandRunRow.created_at)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
CommandRun(
|
||||||
|
id=r.id, session_id=r.session_id, command=r.command,
|
||||||
|
exit_code=r.exit_code, stdout_ref=r.stdout_ref, stderr_ref=r.stderr_ref,
|
||||||
|
duration_ms=r.duration_ms, truncated=r.truncated, created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create(self, run: CommandRun) -> CommandRun:
|
||||||
|
row = CommandRunRow(
|
||||||
|
id=run.id, session_id=run.session_id, command=run.command,
|
||||||
|
exit_code=run.exit_code, stdout_ref=run.stdout_ref, stderr_ref=run.stderr_ref,
|
||||||
|
duration_ms=run.duration_ms, truncated=run.truncated, created_at=run.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_workspace(self, workspace_id: str) -> list[Snapshot]:
|
||||||
|
stmt = (
|
||||||
|
select(SnapshotRow)
|
||||||
|
.where(SnapshotRow.workspace_id == workspace_id)
|
||||||
|
.order_by(SnapshotRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
Snapshot(
|
||||||
|
id=r.id, workspace_id=r.workspace_id, name=r.name,
|
||||||
|
external_snapshot_id=r.external_snapshot_id, created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create(self, snapshot: Snapshot) -> Snapshot:
|
||||||
|
row = SnapshotRow(
|
||||||
|
id=snapshot.id, workspace_id=snapshot.workspace_id, name=snapshot.name,
|
||||||
|
external_snapshot_id=snapshot.external_snapshot_id,
|
||||||
|
created_at=snapshot.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewUrlRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_workspace(self, workspace_id: str) -> list[PreviewUrl]:
|
||||||
|
stmt = select(PreviewUrlRow).where(PreviewUrlRow.workspace_id == workspace_id)
|
||||||
|
return [
|
||||||
|
PreviewUrl(
|
||||||
|
id=r.id, workspace_id=r.workspace_id, port=r.port, url=r.url,
|
||||||
|
created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create(self, preview: PreviewUrl) -> PreviewUrl:
|
||||||
|
row = PreviewUrlRow(
|
||||||
|
id=preview.id, workspace_id=preview.workspace_id, port=preview.port,
|
||||||
|
url=preview.url, created_at=preview.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return preview
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceLimitRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_for_scope(self, scope: str, scope_id: str | None) -> SandboxResourceLimit | None:
|
||||||
|
stmt = select(SandboxResourceLimitRow).where(
|
||||||
|
SandboxResourceLimitRow.scope == scope,
|
||||||
|
SandboxResourceLimitRow.scope_id == scope_id,
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return SandboxResourceLimit(
|
||||||
|
id=row.id, scope=row.scope, scope_id=row.scope_id, cpu=row.cpu,
|
||||||
|
memory_mb=row.memory_mb, disk_mb=row.disk_mb,
|
||||||
|
timeout_seconds=row.timeout_seconds, max_output_bytes=row.max_output_bytes,
|
||||||
|
network_policy=NetworkPolicy(row.network_policy),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upsert(self, limit: SandboxResourceLimit) -> SandboxResourceLimit:
|
||||||
|
stmt = select(SandboxResourceLimitRow).where(
|
||||||
|
SandboxResourceLimitRow.scope == limit.scope,
|
||||||
|
SandboxResourceLimitRow.scope_id == limit.scope_id,
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
row = SandboxResourceLimitRow(
|
||||||
|
id=limit.id, scope=limit.scope, scope_id=limit.scope_id, cpu=limit.cpu,
|
||||||
|
memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||||
|
timeout_seconds=limit.timeout_seconds,
|
||||||
|
max_output_bytes=limit.max_output_bytes,
|
||||||
|
network_policy=str(limit.network_policy),
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
else:
|
||||||
|
row.cpu = limit.cpu
|
||||||
|
row.memory_mb = limit.memory_mb
|
||||||
|
row.disk_mb = limit.disk_mb
|
||||||
|
row.timeout_seconds = limit.timeout_seconds
|
||||||
|
row.max_output_bytes = limit.max_output_bytes
|
||||||
|
row.network_policy = str(limit.network_policy)
|
||||||
|
await self.session.flush()
|
||||||
|
return limit
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CommandRunRepository",
|
||||||
|
"PreviewUrlRepository",
|
||||||
|
"ResourceLimitRepository",
|
||||||
|
"SandboxProviderKind",
|
||||||
|
"SandboxSessionRepository",
|
||||||
|
"SandboxTemplateRepository",
|
||||||
|
"SnapshotRepository",
|
||||||
|
"WorkspaceRepository",
|
||||||
|
]
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
"""Repositories for the AI Workforce bounded context.
|
||||||
|
|
||||||
|
Covers agent templates, employees, prompt versions, tool permissions, and the
|
||||||
|
whole Manager-Chat chain: threads -> messages -> tasks -> runs -> steps ->
|
||||||
|
tool calls -> artifacts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.workforce.models import (
|
||||||
|
AgentTemplate,
|
||||||
|
Artifact,
|
||||||
|
Employee,
|
||||||
|
EmployeeStatus,
|
||||||
|
EmployeeToolPermission,
|
||||||
|
Message,
|
||||||
|
MessageRole,
|
||||||
|
PromptVersion,
|
||||||
|
RoleKey,
|
||||||
|
Run,
|
||||||
|
RunStatus,
|
||||||
|
RunStep,
|
||||||
|
Task,
|
||||||
|
TaskStatus,
|
||||||
|
Thread,
|
||||||
|
ToolCall,
|
||||||
|
ToolCallStatus,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
AgentTemplateRow,
|
||||||
|
ArtifactRow,
|
||||||
|
EmployeeRow,
|
||||||
|
EmployeeToolPermissionRow,
|
||||||
|
MessageRow,
|
||||||
|
PromptVersionRow,
|
||||||
|
RunRow,
|
||||||
|
RunStepRow,
|
||||||
|
TaskRow,
|
||||||
|
ThreadRow,
|
||||||
|
ToolCallRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mappers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _template(row: AgentTemplateRow) -> AgentTemplate:
|
||||||
|
return AgentTemplate(
|
||||||
|
id=row.id, name=row.name, role_key=RoleKey(row.role_key),
|
||||||
|
description=row.description, default_prompt=row.default_prompt,
|
||||||
|
default_tools_json=list(row.default_tools_json or []),
|
||||||
|
default_routing_policy_id=row.default_routing_policy_id,
|
||||||
|
is_system=row.is_system, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _employee(row: EmployeeRow) -> Employee:
|
||||||
|
return Employee(
|
||||||
|
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||||
|
template_id=row.template_id, name=row.name, role_key=RoleKey(row.role_key),
|
||||||
|
status=EmployeeStatus(row.status), routing_policy_id=row.routing_policy_id,
|
||||||
|
budget_limit_json=dict(row.budget_limit_json or {}), created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _thread(row: ThreadRow) -> Thread:
|
||||||
|
return Thread(
|
||||||
|
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||||
|
title=row.title, created_by_user_id=row.created_by_user_id,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message(row: MessageRow) -> Message:
|
||||||
|
return Message(
|
||||||
|
id=row.id, thread_id=row.thread_id, role=MessageRole(row.role),
|
||||||
|
content=row.content, employee_id=row.employee_id, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _task(row: TaskRow) -> Task:
|
||||||
|
return Task(
|
||||||
|
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||||
|
thread_id=row.thread_id, title=row.title, description=row.description,
|
||||||
|
status=TaskStatus(row.status),
|
||||||
|
assigned_employee_id=row.assigned_employee_id,
|
||||||
|
created_by_user_id=row.created_by_user_id,
|
||||||
|
created_at=row.created_at, updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(row: RunRow) -> Run:
|
||||||
|
return Run(
|
||||||
|
id=row.id, task_id=row.task_id, employee_id=row.employee_id,
|
||||||
|
status=RunStatus(row.status), error=row.error, started_at=row.started_at,
|
||||||
|
finished_at=row.finished_at, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_step(row: RunStepRow) -> RunStep:
|
||||||
|
return RunStep(
|
||||||
|
id=row.id, run_id=row.run_id, index=row.index, kind=row.kind,
|
||||||
|
title=row.title, payload_json=dict(row.payload_json or {}),
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_call(row: ToolCallRow) -> ToolCall:
|
||||||
|
return ToolCall(
|
||||||
|
id=row.id, run_id=row.run_id, tool_key=row.tool_key,
|
||||||
|
arguments_json=dict(row.arguments_json or {}),
|
||||||
|
result_json=dict(row.result_json or {}),
|
||||||
|
status=ToolCallStatus(row.status), requires_approval=row.requires_approval,
|
||||||
|
approved_by_user_id=row.approved_by_user_id, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(row: ArtifactRow) -> Artifact:
|
||||||
|
return Artifact(
|
||||||
|
id=row.id, organization_id=row.organization_id, project_id=row.project_id,
|
||||||
|
run_id=row.run_id, name=row.name, kind=row.kind, content_ref=row.content_ref,
|
||||||
|
size_bytes=row.size_bytes, created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Templates & employees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTemplateRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list(self) -> list[AgentTemplate]:
|
||||||
|
stmt = select(AgentTemplateRow).order_by(AgentTemplateRow.role_key)
|
||||||
|
return [_template(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def get(self, template_id: str) -> AgentTemplate | None:
|
||||||
|
row = await self.session.get(AgentTemplateRow, template_id)
|
||||||
|
return _template(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str) -> AgentTemplate | None:
|
||||||
|
stmt = select(AgentTemplateRow).where(AgentTemplateRow.name == name)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _template(row) if row else None
|
||||||
|
|
||||||
|
async def create(self, template: AgentTemplate) -> AgentTemplate:
|
||||||
|
row = AgentTemplateRow(
|
||||||
|
id=template.id, name=template.name, role_key=str(template.role_key),
|
||||||
|
description=template.description, default_prompt=template.default_prompt,
|
||||||
|
default_tools_json=list(template.default_tools_json),
|
||||||
|
default_routing_policy_id=template.default_routing_policy_id,
|
||||||
|
is_system=template.is_system, created_at=template.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _template(row)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, employee_id: str) -> Employee | None:
|
||||||
|
row = await self.session.get(EmployeeRow, employee_id)
|
||||||
|
return _employee(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, employee_id: str) -> Employee | None:
|
||||||
|
"""Tenant-scoped lookup: an id from another org resolves to ``None``."""
|
||||||
|
stmt = select(EmployeeRow).where(
|
||||||
|
EmployeeRow.id == employee_id,
|
||||||
|
EmployeeRow.organization_id == organization_id,
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _employee(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Employee]:
|
||||||
|
stmt = (
|
||||||
|
select(EmployeeRow)
|
||||||
|
.where(EmployeeRow.organization_id == organization_id)
|
||||||
|
.order_by(EmployeeRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_for_project(self, project_id: str) -> list[Employee]:
|
||||||
|
stmt = (
|
||||||
|
select(EmployeeRow)
|
||||||
|
.where(EmployeeRow.project_id == project_id)
|
||||||
|
.order_by(EmployeeRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def count_for_org(self, organization_id: str) -> int:
|
||||||
|
stmt = select(func.count()).select_from(EmployeeRow).where(
|
||||||
|
EmployeeRow.organization_id == organization_id
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
async def create(self, employee: Employee) -> Employee:
|
||||||
|
row = EmployeeRow(
|
||||||
|
id=employee.id, organization_id=employee.organization_id,
|
||||||
|
project_id=employee.project_id, template_id=employee.template_id,
|
||||||
|
name=employee.name, role_key=str(employee.role_key),
|
||||||
|
status=str(employee.status),
|
||||||
|
routing_policy_id=employee.routing_policy_id,
|
||||||
|
budget_limit_json=dict(employee.budget_limit_json),
|
||||||
|
created_at=employee.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _employee(row)
|
||||||
|
|
||||||
|
async def update(self, employee: Employee) -> Employee:
|
||||||
|
row = await self.session.get(EmployeeRow, employee.id)
|
||||||
|
if row is None:
|
||||||
|
raise LookupError("employee not found")
|
||||||
|
row.name = employee.name
|
||||||
|
row.role_key = str(employee.role_key)
|
||||||
|
row.status = str(employee.status)
|
||||||
|
row.project_id = employee.project_id
|
||||||
|
row.routing_policy_id = employee.routing_policy_id
|
||||||
|
row.budget_limit_json = dict(employee.budget_limit_json)
|
||||||
|
await self.session.flush()
|
||||||
|
return _employee(row)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptVersionRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_employee(self, employee_id: str) -> list[PromptVersion]:
|
||||||
|
stmt = (
|
||||||
|
select(PromptVersionRow)
|
||||||
|
.where(PromptVersionRow.employee_id == employee_id)
|
||||||
|
.order_by(PromptVersionRow.version.desc())
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
PromptVersion(
|
||||||
|
id=r.id, employee_id=r.employee_id, version=r.version,
|
||||||
|
content=r.content, created_by_user_id=r.created_by_user_id,
|
||||||
|
created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def latest(self, employee_id: str) -> PromptVersion | None:
|
||||||
|
versions = await self.list_for_employee(employee_id)
|
||||||
|
return versions[0] if versions else None
|
||||||
|
|
||||||
|
async def next_version_number(self, employee_id: str) -> int:
|
||||||
|
stmt = select(func.max(PromptVersionRow.version)).where(
|
||||||
|
PromptVersionRow.employee_id == employee_id
|
||||||
|
)
|
||||||
|
current = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return int(current or 0) + 1
|
||||||
|
|
||||||
|
async def create(self, version: PromptVersion) -> PromptVersion:
|
||||||
|
row = PromptVersionRow(
|
||||||
|
id=version.id, employee_id=version.employee_id, version=version.version,
|
||||||
|
content=version.content, created_by_user_id=version.created_by_user_id,
|
||||||
|
created_at=version.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return PromptVersion(
|
||||||
|
id=row.id, employee_id=row.employee_id, version=row.version,
|
||||||
|
content=row.content, created_by_user_id=row.created_by_user_id,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeToolPermissionRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_employee(self, employee_id: str) -> list[EmployeeToolPermission]:
|
||||||
|
stmt = select(EmployeeToolPermissionRow).where(
|
||||||
|
EmployeeToolPermissionRow.employee_id == employee_id
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
EmployeeToolPermission(
|
||||||
|
id=r.id, employee_id=r.employee_id, tool_key=r.tool_key,
|
||||||
|
allowed=r.allowed, policy_json=dict(r.policy_json or {}),
|
||||||
|
)
|
||||||
|
for r in (await self.session.execute(stmt)).scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
async def is_allowed(self, employee_id: str, tool_key: str) -> bool:
|
||||||
|
stmt = select(EmployeeToolPermissionRow.allowed).where(
|
||||||
|
EmployeeToolPermissionRow.employee_id == employee_id,
|
||||||
|
EmployeeToolPermissionRow.tool_key == tool_key,
|
||||||
|
)
|
||||||
|
value = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
# Deny by default: an absent row means "not permitted".
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
async def set_permission(
|
||||||
|
self, *, employee_id: str, tool_key: str, allowed: bool, policy: dict | None = None
|
||||||
|
) -> EmployeeToolPermission:
|
||||||
|
stmt = select(EmployeeToolPermissionRow).where(
|
||||||
|
EmployeeToolPermissionRow.employee_id == employee_id,
|
||||||
|
EmployeeToolPermissionRow.tool_key == tool_key,
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
row = EmployeeToolPermissionRow(
|
||||||
|
employee_id=employee_id, tool_key=tool_key, allowed=allowed,
|
||||||
|
policy_json=dict(policy or {}),
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
else:
|
||||||
|
row.allowed = allowed
|
||||||
|
row.policy_json = dict(policy or {})
|
||||||
|
await self.session.flush()
|
||||||
|
return EmployeeToolPermission(
|
||||||
|
id=row.id, employee_id=row.employee_id, tool_key=row.tool_key,
|
||||||
|
allowed=row.allowed, policy_json=dict(row.policy_json or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chat: threads & messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, thread_id: str) -> Thread | None:
|
||||||
|
row = await self.session.get(ThreadRow, thread_id)
|
||||||
|
return _thread(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, thread_id: str) -> Thread | None:
|
||||||
|
stmt = select(ThreadRow).where(
|
||||||
|
ThreadRow.id == thread_id, ThreadRow.organization_id == organization_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _thread(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Thread]:
|
||||||
|
stmt = (
|
||||||
|
select(ThreadRow)
|
||||||
|
.where(ThreadRow.organization_id == organization_id)
|
||||||
|
.order_by(ThreadRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_thread(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, thread: Thread) -> Thread:
|
||||||
|
row = ThreadRow(
|
||||||
|
id=thread.id, organization_id=thread.organization_id,
|
||||||
|
project_id=thread.project_id, title=thread.title,
|
||||||
|
created_by_user_id=thread.created_by_user_id, created_at=thread.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _thread(row)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_thread(self, thread_id: str) -> list[Message]:
|
||||||
|
stmt = (
|
||||||
|
select(MessageRow)
|
||||||
|
.where(MessageRow.thread_id == thread_id)
|
||||||
|
.order_by(MessageRow.created_at)
|
||||||
|
)
|
||||||
|
return [_message(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def count_for_thread(self, thread_id: str) -> int:
|
||||||
|
stmt = select(func.count()).select_from(MessageRow).where(
|
||||||
|
MessageRow.thread_id == thread_id
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
async def create(self, message: Message) -> Message:
|
||||||
|
row = MessageRow(
|
||||||
|
id=message.id, thread_id=message.thread_id, role=str(message.role),
|
||||||
|
content=message.content, employee_id=message.employee_id,
|
||||||
|
created_at=message.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _message(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Work: tasks, runs, steps, tool calls, artifacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, task_id: str) -> Task | None:
|
||||||
|
row = await self.session.get(TaskRow, task_id)
|
||||||
|
return _task(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, task_id: str) -> Task | None:
|
||||||
|
stmt = select(TaskRow).where(
|
||||||
|
TaskRow.id == task_id, TaskRow.organization_id == organization_id
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _task(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self,
|
||||||
|
organization_id: str,
|
||||||
|
*,
|
||||||
|
status: TaskStatus | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Task]:
|
||||||
|
stmt = select(TaskRow).where(TaskRow.organization_id == organization_id)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(TaskRow.status == str(status))
|
||||||
|
stmt = stmt.order_by(TaskRow.created_at.desc()).limit(min(limit, 500)).offset(offset)
|
||||||
|
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_for_employee(self, employee_id: str) -> list[Task]:
|
||||||
|
stmt = (
|
||||||
|
select(TaskRow)
|
||||||
|
.where(TaskRow.assigned_employee_id == employee_id)
|
||||||
|
.order_by(TaskRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_runnable(self, *, limit: int = 10) -> list[Task]:
|
||||||
|
"""Tasks the runtime worker should pick up, oldest first.
|
||||||
|
|
||||||
|
Cross-tenant by design: the worker is a platform component, not a
|
||||||
|
tenant. Only ``new``/``triaged`` qualify — anything else is either
|
||||||
|
already executing or closed, and re-running it would double-bill.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(TaskRow)
|
||||||
|
.where(TaskRow.status.in_([str(TaskStatus.NEW), str(TaskStatus.TRIAGED)]))
|
||||||
|
.order_by(TaskRow.created_at.asc())
|
||||||
|
.limit(min(limit, 100))
|
||||||
|
)
|
||||||
|
return [_task(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def count_open_for_employee(self, employee_id: str) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TaskRow)
|
||||||
|
.where(
|
||||||
|
TaskRow.assigned_employee_id == employee_id,
|
||||||
|
TaskRow.status.notin_(
|
||||||
|
[str(TaskStatus.DONE), str(TaskStatus.FAILED), str(TaskStatus.CANCELLED)]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
async def create(self, task: Task) -> Task:
|
||||||
|
row = TaskRow(
|
||||||
|
id=task.id, organization_id=task.organization_id,
|
||||||
|
project_id=task.project_id, thread_id=task.thread_id,
|
||||||
|
title=task.title, description=task.description, status=str(task.status),
|
||||||
|
assigned_employee_id=task.assigned_employee_id,
|
||||||
|
created_by_user_id=task.created_by_user_id,
|
||||||
|
created_at=task.created_at, updated_at=task.updated_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _task(row)
|
||||||
|
|
||||||
|
async def set_status(self, task_id: str, status: TaskStatus) -> Task | None:
|
||||||
|
row = await self.session.get(TaskRow, task_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
row.status = str(status)
|
||||||
|
await self.session.flush()
|
||||||
|
return _task(row)
|
||||||
|
|
||||||
|
|
||||||
|
class RunRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, run_id: str) -> Run | None:
|
||||||
|
row = await self.session.get(RunRow, run_id)
|
||||||
|
return _run(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_task(self, task_id: str) -> list[Run]:
|
||||||
|
stmt = (
|
||||||
|
select(RunRow)
|
||||||
|
.where(RunRow.task_id == task_id)
|
||||||
|
.order_by(RunRow.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, run: Run) -> Run:
|
||||||
|
row = RunRow(
|
||||||
|
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
|
||||||
|
status=str(run.status), error=run.error, started_at=run.started_at,
|
||||||
|
finished_at=run.finished_at, created_at=run.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _run(row)
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
status: RunStatus,
|
||||||
|
*,
|
||||||
|
error: str | None = None,
|
||||||
|
started_at=None,
|
||||||
|
finished_at=None,
|
||||||
|
) -> Run | None:
|
||||||
|
row = await self.session.get(RunRow, run_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
row.status = str(status)
|
||||||
|
if error is not None:
|
||||||
|
row.error = error
|
||||||
|
if started_at is not None:
|
||||||
|
row.started_at = started_at
|
||||||
|
if finished_at is not None:
|
||||||
|
row.finished_at = finished_at
|
||||||
|
await self.session.flush()
|
||||||
|
return _run(row)
|
||||||
|
|
||||||
|
|
||||||
|
class RunStepRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_run(self, run_id: str) -> list[RunStep]:
|
||||||
|
stmt = (
|
||||||
|
select(RunStepRow)
|
||||||
|
.where(RunStepRow.run_id == run_id)
|
||||||
|
.order_by(RunStepRow.index)
|
||||||
|
)
|
||||||
|
return [_run_step(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def next_index(self, run_id: str) -> int:
|
||||||
|
stmt = select(func.max(RunStepRow.index)).where(RunStepRow.run_id == run_id)
|
||||||
|
current = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return int(current if current is not None else -1) + 1
|
||||||
|
|
||||||
|
async def append(
|
||||||
|
self, *, run_id: str, kind: str, title: str = "", payload: dict | None = None
|
||||||
|
) -> RunStep:
|
||||||
|
step = RunStep(
|
||||||
|
run_id=run_id, index=await self.next_index(run_id), kind=kind, title=title,
|
||||||
|
payload_json=dict(payload or {}),
|
||||||
|
)
|
||||||
|
row = RunStepRow(
|
||||||
|
id=step.id, run_id=step.run_id, index=step.index, kind=step.kind,
|
||||||
|
title=step.title, payload_json=step.payload_json, created_at=step.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _run_step(row)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, call_id: str) -> ToolCall | None:
|
||||||
|
row = await self.session.get(ToolCallRow, call_id)
|
||||||
|
return _tool_call(row) if row else None
|
||||||
|
|
||||||
|
async def get_for_org(self, organization_id: str, call_id: str) -> ToolCall | None:
|
||||||
|
"""Tenant-scoped lookup, resolved through run -> task -> organization."""
|
||||||
|
stmt = (
|
||||||
|
select(ToolCallRow)
|
||||||
|
.join(RunRow, RunRow.id == ToolCallRow.run_id)
|
||||||
|
.join(TaskRow, TaskRow.id == RunRow.task_id)
|
||||||
|
.where(ToolCallRow.id == call_id, TaskRow.organization_id == organization_id)
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _tool_call(row) if row else None
|
||||||
|
|
||||||
|
async def list_for_run(self, run_id: str) -> list[ToolCall]:
|
||||||
|
stmt = (
|
||||||
|
select(ToolCallRow)
|
||||||
|
.where(ToolCallRow.run_id == run_id)
|
||||||
|
.order_by(ToolCallRow.created_at)
|
||||||
|
)
|
||||||
|
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_pending_approvals_for_org(
|
||||||
|
self, organization_id: str
|
||||||
|
) -> list[ToolCall]:
|
||||||
|
"""Tool calls awaiting approval, scoped through the owning run -> task -> org."""
|
||||||
|
stmt = (
|
||||||
|
select(ToolCallRow)
|
||||||
|
.join(RunRow, RunRow.id == ToolCallRow.run_id)
|
||||||
|
.join(TaskRow, TaskRow.id == RunRow.task_id)
|
||||||
|
.where(
|
||||||
|
TaskRow.organization_id == organization_id,
|
||||||
|
ToolCallRow.status == str(ToolCallStatus.AWAITING_APPROVAL),
|
||||||
|
)
|
||||||
|
.order_by(ToolCallRow.created_at)
|
||||||
|
)
|
||||||
|
return [_tool_call(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, call: ToolCall) -> ToolCall:
|
||||||
|
row = ToolCallRow(
|
||||||
|
id=call.id, run_id=call.run_id, tool_key=call.tool_key,
|
||||||
|
arguments_json=dict(call.arguments_json),
|
||||||
|
result_json=dict(call.result_json), status=str(call.status),
|
||||||
|
requires_approval=call.requires_approval,
|
||||||
|
approved_by_user_id=call.approved_by_user_id, created_at=call.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _tool_call(row)
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self,
|
||||||
|
call_id: str,
|
||||||
|
status: ToolCallStatus,
|
||||||
|
*,
|
||||||
|
result: dict | None = None,
|
||||||
|
approved_by_user_id: str | None = None,
|
||||||
|
) -> ToolCall | None:
|
||||||
|
row = await self.session.get(ToolCallRow, call_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
row.status = str(status)
|
||||||
|
if result is not None:
|
||||||
|
row.result_json = dict(result)
|
||||||
|
if approved_by_user_id is not None:
|
||||||
|
row.approved_by_user_id = approved_by_user_id
|
||||||
|
await self.session.flush()
|
||||||
|
return _tool_call(row)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self, organization_id: str, *, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[Artifact]:
|
||||||
|
stmt = (
|
||||||
|
select(ArtifactRow)
|
||||||
|
.where(ArtifactRow.organization_id == organization_id)
|
||||||
|
.order_by(ArtifactRow.created_at.desc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_for_run(self, run_id: str) -> list[Artifact]:
|
||||||
|
stmt = (
|
||||||
|
select(ArtifactRow)
|
||||||
|
.where(ArtifactRow.run_id == run_id)
|
||||||
|
.order_by(ArtifactRow.created_at)
|
||||||
|
)
|
||||||
|
return [_artifact(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def create(self, artifact: Artifact) -> Artifact:
|
||||||
|
row = ArtifactRow(
|
||||||
|
id=artifact.id, organization_id=artifact.organization_id,
|
||||||
|
project_id=artifact.project_id, run_id=artifact.run_id,
|
||||||
|
name=artifact.name, kind=artifact.kind, content_ref=artifact.content_ref,
|
||||||
|
size_bytes=artifact.size_bytes, created_at=artifact.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _artifact(row)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Translate domain errors into HTTP responses — and record them.
|
||||||
|
|
||||||
|
The inner layers never import FastAPI; this module is the only place that knows
|
||||||
|
about status codes. It is also the single choke point through which every
|
||||||
|
request-scoped failure passes, which makes it the right place to persist them.
|
||||||
|
|
||||||
|
What gets recorded, and why:
|
||||||
|
|
||||||
|
* **Unhandled exceptions and 5xx ``AppError``s** — always. A provider being down
|
||||||
|
or a quota check failing is an operational fault someone must see.
|
||||||
|
* **A bare ``ValueError`` escaping to the handler** — always, at ``warning``.
|
||||||
|
The codebase has a typed ``ValidationError`` for expected bad input and uses
|
||||||
|
it 30 times, so a bare ``ValueError`` reaching here is usually a typing gap
|
||||||
|
worth reviewing. The set of call sites is small and bounded.
|
||||||
|
* **Other 4xx** — only when ``ERROR_LOG_CLIENT_ERRORS`` is on. Most 4xx are a
|
||||||
|
caller sending bad input, and a public API receives a steady stream of 404s
|
||||||
|
from scanners; recording all of them by default would bury the real bugs.
|
||||||
|
Recorded at ``warning`` so they never look like faults.
|
||||||
|
|
||||||
|
Every error body carries the request id, and every response carries it in the
|
||||||
|
``X-Request-ID`` header, so a user can quote one string and land on exactly one
|
||||||
|
row in the console.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||||
|
from app.infrastructure.error_log import record_error
|
||||||
|
from app.interfaces.http.request_context import REQUEST_ID_HEADER, context_of
|
||||||
|
|
||||||
|
logger = logging.getLogger("agy.api")
|
||||||
|
|
||||||
|
#: Statuses at or above this are "the server is broken".
|
||||||
|
SERVER_ERROR_THRESHOLD = 500
|
||||||
|
|
||||||
|
#: Cap on the per-field validation detail echoed back and stored. A caller
|
||||||
|
#: sending a 10 000-element array should not produce a 10 000-line error row.
|
||||||
|
MAX_VALIDATION_ERRORS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _request_id(request: Request) -> str:
|
||||||
|
context = context_of(request)
|
||||||
|
return context.request_id if context else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _error_response(
|
||||||
|
*,
|
||||||
|
status_code: int,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
request_id: str,
|
||||||
|
details: object = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""The one place an error response is built.
|
||||||
|
|
||||||
|
The request id goes in the header as well as the body, and it has to be set
|
||||||
|
*here* rather than by the middleware: an unhandled exception is turned into a
|
||||||
|
response by Starlette's ``ServerErrorMiddleware``, which sits outside every
|
||||||
|
user middleware, so the middleware never sees the response to add a header
|
||||||
|
to. The body alone would leave an HTTP client that only reads headers with
|
||||||
|
no way to correlate the failure.
|
||||||
|
"""
|
||||||
|
error: dict = {"code": code, "message": message, "request_id": request_id}
|
||||||
|
if details is not None:
|
||||||
|
error["details"] = details
|
||||||
|
headers = {REQUEST_ID_HEADER: request_id} if request_id else None
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status_code, content={"error": error}, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _record(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
message: str,
|
||||||
|
status_code: int | None,
|
||||||
|
exc: BaseException | None = None,
|
||||||
|
severity: str = ErrorSeverity.ERROR,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a request-scoped failure. Never raises."""
|
||||||
|
context = context_of(request)
|
||||||
|
try:
|
||||||
|
await record_error(
|
||||||
|
kind=kind,
|
||||||
|
message=message,
|
||||||
|
exc=exc,
|
||||||
|
source=ErrorSource.HTTP,
|
||||||
|
severity=severity,
|
||||||
|
status_code=status_code,
|
||||||
|
method=context.method if context else request.method,
|
||||||
|
path=context.path if context else request.url.path,
|
||||||
|
organization_id=context.organization_id if context else None,
|
||||||
|
user_id=context.user_id if context else None,
|
||||||
|
run_id=context.run_id if context else None,
|
||||||
|
request_id=context.request_id if context else "",
|
||||||
|
ip=context.ip if context else "",
|
||||||
|
user_agent=context.user_agent if context else "",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - the recorder is already total
|
||||||
|
logger.exception("error-log: unexpected failure while recording an error")
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
is_server_fault = exc.status_code >= SERVER_ERROR_THRESHOLD
|
||||||
|
if settings.error_log_enabled and (is_server_fault or settings.error_log_client_errors):
|
||||||
|
await _record(
|
||||||
|
request,
|
||||||
|
kind=exc.code,
|
||||||
|
message=exc.message,
|
||||||
|
status_code=exc.status_code,
|
||||||
|
# Pass the exception so the stack is captured: a
|
||||||
|
# ProviderUnavailable raised three layers down is far easier to
|
||||||
|
# fix with the traceback than with the message alone.
|
||||||
|
exc=exc,
|
||||||
|
severity=ErrorSeverity.ERROR if is_server_fault else ErrorSeverity.WARNING,
|
||||||
|
metadata={"details": exc.details} if exc.details is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _error_response(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
code=exc.code,
|
||||||
|
message=exc.message,
|
||||||
|
request_id=_request_id(request),
|
||||||
|
details=exc.details,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def _validation_error_handler(
|
||||||
|
request: Request, exc: RequestValidationError
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Reject malformed input in the standard envelope.
|
||||||
|
|
||||||
|
FastAPI's default body is ``{"detail": [...]}``, which the web client
|
||||||
|
cannot read — it would surface every validation failure as a generic
|
||||||
|
"server error (422)". Same status, useful shape.
|
||||||
|
"""
|
||||||
|
errors = [
|
||||||
|
{
|
||||||
|
"loc": [str(part) for part in err.get("loc", ())],
|
||||||
|
"msg": str(err.get("msg", "")),
|
||||||
|
}
|
||||||
|
for err in exc.errors()[:MAX_VALIDATION_ERRORS]
|
||||||
|
]
|
||||||
|
if settings.error_log_enabled and settings.error_log_client_errors:
|
||||||
|
await _record(
|
||||||
|
request,
|
||||||
|
kind="request_validation_error",
|
||||||
|
message="; ".join(f"{'.'.join(e['loc'])}: {e['msg']}" for e in errors),
|
||||||
|
status_code=422,
|
||||||
|
severity=ErrorSeverity.WARNING,
|
||||||
|
)
|
||||||
|
return _error_response(
|
||||||
|
status_code=422,
|
||||||
|
code="validation_error",
|
||||||
|
message="دادههای ارسالی نامعتبر است.",
|
||||||
|
request_id=_request_id(request),
|
||||||
|
details=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def _value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
|
||||||
|
if settings.error_log_enabled:
|
||||||
|
await _record(
|
||||||
|
request,
|
||||||
|
kind="value_error",
|
||||||
|
message=str(exc),
|
||||||
|
status_code=422,
|
||||||
|
exc=exc,
|
||||||
|
severity=ErrorSeverity.WARNING,
|
||||||
|
)
|
||||||
|
return _error_response(
|
||||||
|
status_code=422,
|
||||||
|
code="validation_error",
|
||||||
|
message=str(exc),
|
||||||
|
request_id=_request_id(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def _unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
"""The last line of defence: an unexpected failure.
|
||||||
|
|
||||||
|
Recorded unconditionally when logging is enabled — this is precisely the
|
||||||
|
class of error the console exists for.
|
||||||
|
"""
|
||||||
|
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
||||||
|
if settings.error_log_enabled:
|
||||||
|
await _record(
|
||||||
|
request,
|
||||||
|
kind="unhandled_exception",
|
||||||
|
message=str(exc) or type(exc).__name__,
|
||||||
|
status_code=500,
|
||||||
|
exc=exc,
|
||||||
|
severity=ErrorSeverity.CRITICAL,
|
||||||
|
)
|
||||||
|
return _error_response(
|
||||||
|
status_code=500,
|
||||||
|
code="internal_error",
|
||||||
|
message="خطای داخلی سرور. لطفاً دوباره تلاش کنید.",
|
||||||
|
request_id=_request_id(request),
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Per-request correlation and actor context.
|
||||||
|
|
||||||
|
Every request gets an id before it reaches a route. That id is returned in the
|
||||||
|
``X-Request-ID`` response header, included in every error body, and written onto
|
||||||
|
every error record — so a user reporting "it broke" can hand over one string
|
||||||
|
that points at exactly one row in the error console.
|
||||||
|
|
||||||
|
The same object carries the resolved tenant and user. Authentication happens in
|
||||||
|
dependencies, long after the middleware has run, so the middleware seeds the
|
||||||
|
context with what it knows (method, path, ip, user agent) and the auth
|
||||||
|
dependencies fill in the rest via :func:`note_actor`. Error handlers then read
|
||||||
|
one object instead of re-deriving anything.
|
||||||
|
|
||||||
|
Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is
|
||||||
|
the same object the handler already has in hand, it survives every task
|
||||||
|
boundary Starlette may introduce, and it cannot leak between requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
#: Header used to both accept a caller-supplied id and echo it back. Accepting
|
||||||
|
#: an upstream id matters when the API sits behind a reverse proxy or a
|
||||||
|
#: frontend BFF that already correlates its own logs.
|
||||||
|
REQUEST_ID_HEADER = "X-Request-ID"
|
||||||
|
|
||||||
|
#: Long enough to be unique, short enough to paste into a bug report.
|
||||||
|
REQUEST_ID_LENGTH = 16
|
||||||
|
|
||||||
|
_STATE_ATTR = "error_context"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RequestContext:
|
||||||
|
"""What we know about the request an error happened on."""
|
||||||
|
|
||||||
|
request_id: str
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
ip: str = ""
|
||||||
|
user_agent: str = ""
|
||||||
|
#: Filled in by the auth dependencies once the caller is authenticated.
|
||||||
|
organization_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
#: Set when a failure happens inside a specific agent run.
|
||||||
|
run_id: str | None = None
|
||||||
|
#: Query string, kept separate from ``path`` so fingerprints group across
|
||||||
|
#: different filters on the same endpoint.
|
||||||
|
query: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def new_request_id() -> str:
|
||||||
|
return uuid.uuid4().hex[:REQUEST_ID_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Real client address, honouring one proxy hop.
|
||||||
|
|
||||||
|
Same rule as ``deps.client_meta``: a trusted reverse proxy sets
|
||||||
|
``X-Forwarded-For``, and the left-most entry is the original caller.
|
||||||
|
"""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
|
def context_of(request: Request) -> RequestContext | None:
|
||||||
|
"""The context for this request, or ``None`` outside the middleware."""
|
||||||
|
return getattr(request.state, _STATE_ATTR, None)
|
||||||
|
|
||||||
|
|
||||||
|
def note_actor(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
user_id: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record who/which tenant this request belongs to.
|
||||||
|
|
||||||
|
Called from the auth dependencies. Silent when there is no context (a
|
||||||
|
direct unit-test call to a dependency, for instance) — correlation is
|
||||||
|
telemetry, and telemetry must never be the reason a request fails.
|
||||||
|
"""
|
||||||
|
context = context_of(request)
|
||||||
|
if context is None:
|
||||||
|
return
|
||||||
|
if user_id:
|
||||||
|
context.user_id = user_id
|
||||||
|
if organization_id:
|
||||||
|
context.organization_id = organization_id
|
||||||
|
if run_id:
|
||||||
|
context.run_id = run_id
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Attach a :class:`RequestContext` to every request and echo its id."""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: RequestResponseEndpoint
|
||||||
|
) -> Response:
|
||||||
|
incoming = request.headers.get(REQUEST_ID_HEADER, "").strip()
|
||||||
|
# Cap the accepted value: an unbounded header would let a caller write
|
||||||
|
# arbitrarily long ids into the error table.
|
||||||
|
request_id = incoming[:64] if incoming else new_request_id()
|
||||||
|
|
||||||
|
setattr( # noqa: B010 - the attribute name is a module constant
|
||||||
|
request.state,
|
||||||
|
_STATE_ATTR,
|
||||||
|
RequestContext(
|
||||||
|
request_id=request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
query=request.url.query or "",
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent", "")[:255],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers[REQUEST_ID_HEADER] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"REQUEST_ID_HEADER",
|
||||||
|
"RequestContext",
|
||||||
|
"RequestContextMiddleware",
|
||||||
|
"client_ip",
|
||||||
|
"context_of",
|
||||||
|
"new_request_id",
|
||||||
|
"note_actor",
|
||||||
|
]
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
"""Platform owner console: providers, models, routing policies and rate cards.
|
||||||
|
|
||||||
|
Everything in this router is invisible to customers. Two rules are enforced
|
||||||
|
here and tested:
|
||||||
|
|
||||||
|
1. Only a platform owner may call any of it.
|
||||||
|
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
|
||||||
|
a nested object. Only ``masked_key`` leaves this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.core.crypto import encrypt, mask
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||||
|
from app.domain.ai_control.models import (
|
||||||
|
AiModel,
|
||||||
|
Capability,
|
||||||
|
ModelCredential,
|
||||||
|
Provider,
|
||||||
|
ProviderType,
|
||||||
|
RateCard,
|
||||||
|
RateUnit,
|
||||||
|
RoutingMode,
|
||||||
|
RoutingPolicy,
|
||||||
|
)
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.base import utcnow
|
||||||
|
from app.domain.identity.models import User
|
||||||
|
from app.infrastructure.repositories.ai_control import (
|
||||||
|
AiModelRepository,
|
||||||
|
ModelCredentialRepository,
|
||||||
|
ProviderRepository,
|
||||||
|
RateCardRepository,
|
||||||
|
RoutingPolicyRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.audit import (
|
||||||
|
ObservabilityRepository,
|
||||||
|
UsageLedgerRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.identity import (
|
||||||
|
MembershipRepository,
|
||||||
|
OrganizationRepository,
|
||||||
|
)
|
||||||
|
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin",
|
||||||
|
tags=["admin"],
|
||||||
|
# Deny-by-default for the whole console. This is a router-level dependency on
|
||||||
|
# purpose: an endpoint added later is protected without its author having to
|
||||||
|
# remember. Relying on per-endpoint checks alone already caused a real
|
||||||
|
# privilege-escalation bug — every GET here once shipped without the check,
|
||||||
|
# letting any authenticated customer list all providers and every
|
||||||
|
# organization on the platform.
|
||||||
|
#
|
||||||
|
# Endpoints that also declare `owner: User = Depends(require_platform_owner)`
|
||||||
|
# do so because they *use* the object (for the audit actor); FastAPI caches
|
||||||
|
# the dependency within a request, so it is not evaluated twice.
|
||||||
|
dependencies=[Depends(require_platform_owner)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
base_url: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProviderRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=120)
|
||||||
|
type: ProviderType = ProviderType.MOCK
|
||||||
|
base_url: str = Field(default="", max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialOut(BaseModel):
|
||||||
|
"""Deliberately has no field capable of holding the plaintext key."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
provider_id: str
|
||||||
|
name: str
|
||||||
|
masked_key: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCredentialRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
api_key: str = Field(min_length=4, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
provider_id: str
|
||||||
|
internal_name: str
|
||||||
|
display_name_for_admin: str
|
||||||
|
capabilities: list[str]
|
||||||
|
input_price_per_1m: float
|
||||||
|
output_price_per_1m: float
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CreateModelRequest(BaseModel):
|
||||||
|
provider_id: str
|
||||||
|
internal_name: str = Field(min_length=1, max_length=160)
|
||||||
|
display_name_for_admin: str = Field(min_length=1, max_length=160)
|
||||||
|
capabilities: list[Capability] = Field(default_factory=list)
|
||||||
|
input_price_per_1m: float = Field(default=0.0, ge=0)
|
||||||
|
output_price_per_1m: float = Field(default=0.0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingPolicyOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
mode: str
|
||||||
|
is_default: bool
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoutingPolicyRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=120)
|
||||||
|
mode: RoutingMode = RoutingMode.BALANCED
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
is_default: bool = False
|
||||||
|
budget_limits_json: dict = Field(default_factory=dict)
|
||||||
|
fallback_policy_json: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RateCardOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
unit: str
|
||||||
|
customer_price: float
|
||||||
|
internal_cost_estimate: float
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UpsertRateCardRequest(BaseModel):
|
||||||
|
unit: RateUnit
|
||||||
|
name: str = Field(default="", max_length=120)
|
||||||
|
customer_price: float = Field(ge=0)
|
||||||
|
internal_cost_estimate: float = Field(default=0.0, ge=0)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminOrganizationOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
member_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUsageRow(BaseModel):
|
||||||
|
organization_id: str
|
||||||
|
quantity: float
|
||||||
|
total_price: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Providers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=list[ProviderOut])
|
||||||
|
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
|
||||||
|
providers = await ProviderRepository(session).list()
|
||||||
|
return [
|
||||||
|
ProviderOut(
|
||||||
|
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
|
||||||
|
is_active=p.is_active, created_at=p.created_at,
|
||||||
|
)
|
||||||
|
for p in providers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_provider(
|
||||||
|
payload: CreateProviderRequest,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ProviderOut:
|
||||||
|
repo = ProviderRepository(session)
|
||||||
|
if await repo.get_by_name(payload.name) is not None:
|
||||||
|
raise ConflictError("ارائهدهندهای با این نام وجود دارد.")
|
||||||
|
provider = await repo.create(
|
||||||
|
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
|
||||||
|
target_type="provider", target_id=provider.id,
|
||||||
|
metadata={"name": provider.name, "type": provider.type.value},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return ProviderOut(
|
||||||
|
id=provider.id, name=provider.name, type=provider.type.value,
|
||||||
|
base_url=provider.base_url, is_active=provider.is_active,
|
||||||
|
created_at=provider.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider_id}/credentials",
|
||||||
|
response_model=CredentialOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_credential(
|
||||||
|
provider_id: str,
|
||||||
|
payload: CreateCredentialRequest,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CredentialOut:
|
||||||
|
provider = await ProviderRepository(session).get(provider_id)
|
||||||
|
if provider is None:
|
||||||
|
raise NotFoundError("ارائهدهنده یافت نشد.")
|
||||||
|
|
||||||
|
credential = await ModelCredentialRepository(session).create(
|
||||||
|
ModelCredential(
|
||||||
|
provider_id=provider_id,
|
||||||
|
name=payload.name,
|
||||||
|
# Encrypted at rest; the plaintext exists only inside this request.
|
||||||
|
encrypted_api_key=encrypt(payload.api_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
|
||||||
|
target_type="credential", target_id=credential.id,
|
||||||
|
metadata={"provider_id": provider_id, "name": credential.name},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return CredentialOut(
|
||||||
|
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||||
|
masked_key=mask(payload.api_key), is_active=credential.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
|
||||||
|
async def list_credentials(
|
||||||
|
provider_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> list[CredentialOut]:
|
||||||
|
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
|
||||||
|
return [
|
||||||
|
CredentialOut(
|
||||||
|
id=c.id, provider_id=c.provider_id, name=c.name,
|
||||||
|
# We cannot recover the plaintext to mask it — and we must not try.
|
||||||
|
masked_key="••••••",
|
||||||
|
is_active=c.is_active,
|
||||||
|
)
|
||||||
|
for c in credentials
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=list[ModelOut])
|
||||||
|
async def list_models(
|
||||||
|
provider_id: str | None = Query(default=None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[ModelOut]:
|
||||||
|
models = await AiModelRepository(session).list(provider_id=provider_id)
|
||||||
|
return [
|
||||||
|
ModelOut(
|
||||||
|
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
|
||||||
|
display_name_for_admin=m.display_name_for_admin,
|
||||||
|
capabilities=list(m.capabilities),
|
||||||
|
input_price_per_1m=m.input_price_per_1m,
|
||||||
|
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
|
||||||
|
)
|
||||||
|
for m in models
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_model(
|
||||||
|
payload: CreateModelRequest,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ModelOut:
|
||||||
|
if await ProviderRepository(session).get(payload.provider_id) is None:
|
||||||
|
raise NotFoundError("ارائهدهنده یافت نشد.")
|
||||||
|
|
||||||
|
model = await AiModelRepository(session).create(
|
||||||
|
AiModel(
|
||||||
|
provider_id=payload.provider_id,
|
||||||
|
internal_name=payload.internal_name,
|
||||||
|
display_name_for_admin=payload.display_name_for_admin,
|
||||||
|
capabilities=[str(c) for c in payload.capabilities],
|
||||||
|
input_price_per_1m=payload.input_price_per_1m,
|
||||||
|
output_price_per_1m=payload.output_price_per_1m,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
|
||||||
|
target_type="model", target_id=model.id,
|
||||||
|
metadata={"internal_name": model.internal_name},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return ModelOut(
|
||||||
|
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
|
||||||
|
display_name_for_admin=model.display_name_for_admin,
|
||||||
|
capabilities=list(model.capabilities),
|
||||||
|
input_price_per_1m=model.input_price_per_1m,
|
||||||
|
output_price_per_1m=model.output_price_per_1m, is_active=model.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routing policies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/routing-policies", response_model=list[RoutingPolicyOut])
|
||||||
|
async def list_routing_policies(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[RoutingPolicyOut]:
|
||||||
|
policies = await RoutingPolicyRepository(session).list()
|
||||||
|
return [
|
||||||
|
RoutingPolicyOut(
|
||||||
|
id=p.id, name=p.name, description=p.description, mode=p.mode.value,
|
||||||
|
is_default=p.is_default, is_active=p.is_active,
|
||||||
|
)
|
||||||
|
for p in policies
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/routing-policies", response_model=RoutingPolicyOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_routing_policy(
|
||||||
|
payload: CreateRoutingPolicyRequest,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> RoutingPolicyOut:
|
||||||
|
policy = await RoutingPolicyRepository(session).create(
|
||||||
|
RoutingPolicy(
|
||||||
|
name=payload.name, mode=payload.mode, description=payload.description,
|
||||||
|
is_default=payload.is_default,
|
||||||
|
budget_limits_json=payload.budget_limits_json,
|
||||||
|
fallback_policy_json=payload.fallback_policy_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.ROUTING_CHANGE, actor_user_id=owner.id,
|
||||||
|
target_type="routing_policy", target_id=policy.id,
|
||||||
|
metadata={"name": policy.name, "mode": policy.mode.value},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return RoutingPolicyOut(
|
||||||
|
id=policy.id, name=policy.name, description=policy.description,
|
||||||
|
mode=policy.mode.value, is_default=policy.is_default, is_active=policy.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rate cards
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rate-cards", response_model=list[RateCardOut])
|
||||||
|
async def list_rate_cards(session: AsyncSession = Depends(get_session)) -> list[RateCardOut]:
|
||||||
|
cards = await RateCardRepository(session).list()
|
||||||
|
return [
|
||||||
|
RateCardOut(
|
||||||
|
id=c.id, name=c.name, unit=c.unit.value, customer_price=c.customer_price,
|
||||||
|
internal_cost_estimate=c.internal_cost_estimate, is_active=c.is_active,
|
||||||
|
)
|
||||||
|
for c in cards
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/rate-cards", response_model=RateCardOut)
|
||||||
|
async def upsert_rate_card(
|
||||||
|
payload: UpsertRateCardRequest,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> RateCardOut:
|
||||||
|
card = await RateCardRepository(session).upsert(
|
||||||
|
RateCard(
|
||||||
|
name=payload.name or payload.unit.value.replace("_", " "),
|
||||||
|
unit=payload.unit,
|
||||||
|
customer_price=payload.customer_price,
|
||||||
|
internal_cost_estimate=payload.internal_cost_estimate,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.BILLING_CREDIT, actor_user_id=owner.id,
|
||||||
|
target_type="rate_card", target_id=card.id,
|
||||||
|
metadata={"unit": card.unit.value, "customer_price": card.customer_price},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return RateCardOut(
|
||||||
|
id=card.id, name=card.name, unit=card.unit.value,
|
||||||
|
customer_price=card.customer_price,
|
||||||
|
internal_cost_estimate=card.internal_cost_estimate, is_active=card.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-organization visibility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/organizations", response_model=list[AdminOrganizationOut])
|
||||||
|
async def list_all_organizations(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[AdminOrganizationOut]:
|
||||||
|
organizations = await OrganizationRepository(session).list_all()
|
||||||
|
memberships = MembershipRepository(session)
|
||||||
|
out: list[AdminOrganizationOut] = []
|
||||||
|
for org in organizations:
|
||||||
|
members = await memberships.list_for_org(org.id)
|
||||||
|
out.append(
|
||||||
|
AdminOrganizationOut(
|
||||||
|
id=org.id, name=org.name, slug=org.slug,
|
||||||
|
member_count=len(members), created_at=org.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage", response_model=list[AdminUsageRow])
|
||||||
|
async def platform_usage(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[AdminUsageRow]:
|
||||||
|
rows = await UsageLedgerRepository(session).platform_totals()
|
||||||
|
return [
|
||||||
|
AdminUsageRow(
|
||||||
|
organization_id=org_id, quantity=quantity, total_price=total_price
|
||||||
|
)
|
||||||
|
for org_id, quantity, total_price in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-model-choice")
|
||||||
|
async def validate_model_choice(
|
||||||
|
mode: RoutingMode,
|
||||||
|
_: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Dry-run of routing: which model would this mode pick, and why?"""
|
||||||
|
model = await AiModelRepository(session).pick_for_mode(mode)
|
||||||
|
if model is None:
|
||||||
|
raise ValidationError("هیچ مدل فعالی برای این حالت مسیریابی وجود ندارد.")
|
||||||
|
return {
|
||||||
|
"mode": mode.value,
|
||||||
|
"model_id": model.id,
|
||||||
|
"internal_name": model.internal_name,
|
||||||
|
"capabilities": list(model.capabilities),
|
||||||
|
"estimated_input_price_per_1m": model.input_price_per_1m,
|
||||||
|
"estimated_output_price_per_1m": model.output_price_per_1m,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error console
|
||||||
|
#
|
||||||
|
# Every failure the platform catches lands in ``error_events``. This is where a
|
||||||
|
# platform owner reads them: grouped by fingerprint so one recurring bug is one
|
||||||
|
# line, with the full record (stack, request id, tenant, actor) one click away.
|
||||||
|
#
|
||||||
|
# Resolution is *per fingerprint* for the group view — fixing a bug means the
|
||||||
|
# whole group is dealt with — and per row for a single occurrence.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorEventOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
message: str
|
||||||
|
stack: str
|
||||||
|
severity: str
|
||||||
|
source: str
|
||||||
|
error_type: str
|
||||||
|
fingerprint: str
|
||||||
|
status_code: int | None
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
organization_id: str | None
|
||||||
|
user_id: str | None
|
||||||
|
run_id: str | None
|
||||||
|
request_id: str
|
||||||
|
ip: str
|
||||||
|
user_agent: str
|
||||||
|
metadata_json: dict
|
||||||
|
resolved_at: datetime | None
|
||||||
|
resolved_note: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorGroupOut(BaseModel):
|
||||||
|
fingerprint: str
|
||||||
|
occurrences: int
|
||||||
|
open_count: int
|
||||||
|
first_seen: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
kind: str
|
||||||
|
error_type: str
|
||||||
|
severity: str
|
||||||
|
source: str
|
||||||
|
path: str
|
||||||
|
sample_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorSummaryOut(BaseModel):
|
||||||
|
open_by_severity: dict[str, int]
|
||||||
|
total_open: int
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorListOut(BaseModel):
|
||||||
|
items: list[ErrorEventOut]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveErrorIn(BaseModel):
|
||||||
|
note: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveErrorOut(BaseModel):
|
||||||
|
fingerprint: str
|
||||||
|
resolved: int
|
||||||
|
|
||||||
|
|
||||||
|
#: Newest-first page size ceiling. An unbounded ``limit`` would let one request
|
||||||
|
#: pull the entire table — including every stack trace — into memory.
|
||||||
|
MAX_ERROR_PAGE_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/errors", response_model=ErrorListOut)
|
||||||
|
async def list_errors(
|
||||||
|
severity: str | None = Query(default=None),
|
||||||
|
source: str | None = Query(default=None),
|
||||||
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
|
kind: str | None = Query(default=None),
|
||||||
|
organization_id: str | None = Query(default=None),
|
||||||
|
fingerprint: str | None = Query(default=None),
|
||||||
|
search: str | None = Query(default=None, max_length=200),
|
||||||
|
since: datetime | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ErrorListOut:
|
||||||
|
"""Individual occurrences, newest first."""
|
||||||
|
repo = ObservabilityRepository(session)
|
||||||
|
filters = {
|
||||||
|
"severity": severity,
|
||||||
|
"source": source,
|
||||||
|
"status": status_filter,
|
||||||
|
"kind": kind,
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"search": search,
|
||||||
|
"since": since,
|
||||||
|
}
|
||||||
|
items = await repo.list_errors(limit=limit, offset=offset, **filters)
|
||||||
|
total = await repo.count_errors(**filters)
|
||||||
|
return ErrorListOut(
|
||||||
|
items=[ErrorEventOut.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/errors/summary", response_model=ErrorSummaryOut)
|
||||||
|
async def error_summary(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ErrorSummaryOut:
|
||||||
|
"""Unresolved counts, for the console's header tiles."""
|
||||||
|
by_severity = await ObservabilityRepository(session).count_open_by_severity()
|
||||||
|
return ErrorSummaryOut(
|
||||||
|
open_by_severity=by_severity, total_open=sum(by_severity.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Declared before ``/errors/{error_id}`` on purpose: FastAPI matches routes in
|
||||||
|
# declaration order, so the other way round "groups" would be captured as an id.
|
||||||
|
@router.get("/errors/groups", response_model=list[ErrorGroupOut])
|
||||||
|
async def list_error_groups(
|
||||||
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
|
severity: str | None = Query(default=None),
|
||||||
|
source: str | None = Query(default=None),
|
||||||
|
organization_id: str | None = Query(default=None),
|
||||||
|
since: datetime | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[ErrorGroupOut]:
|
||||||
|
"""One row per distinct bug, most recently active first."""
|
||||||
|
groups = await ObservabilityRepository(session).list_error_groups(
|
||||||
|
limit=limit,
|
||||||
|
since=since,
|
||||||
|
status=status_filter,
|
||||||
|
severity=severity,
|
||||||
|
source=source,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return [ErrorGroupOut(**group) for group in groups]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/errors/{error_id}", response_model=ErrorEventOut)
|
||||||
|
async def get_error(
|
||||||
|
error_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ErrorEventOut:
|
||||||
|
event = await ObservabilityRepository(session).get_error(error_id)
|
||||||
|
if event is None:
|
||||||
|
raise NotFoundError("خطای مورد نظر یافت نشد.")
|
||||||
|
return ErrorEventOut.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/errors/{error_id}/resolve", response_model=ResolveErrorOut)
|
||||||
|
async def resolve_error(
|
||||||
|
error_id: str,
|
||||||
|
payload: ResolveErrorIn,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ResolveErrorOut:
|
||||||
|
"""Mark one occurrence as dealt with."""
|
||||||
|
repo = ObservabilityRepository(session)
|
||||||
|
event = await repo.resolve_error(error_id, note=payload.note, resolved_at=utcnow())
|
||||||
|
if event is None:
|
||||||
|
raise NotFoundError("خطای مورد نظر یافت نشد.")
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.ERROR_RESOLVE,
|
||||||
|
actor_user_id=owner.id,
|
||||||
|
target_type="error_event",
|
||||||
|
target_id=error_id,
|
||||||
|
metadata={"fingerprint": event.fingerprint, "note": payload.note[:200]},
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
return ResolveErrorOut(fingerprint=event.fingerprint, resolved=1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/errors/groups/{fingerprint}/resolve", response_model=ResolveErrorOut)
|
||||||
|
async def resolve_error_group(
|
||||||
|
fingerprint: str,
|
||||||
|
payload: ResolveErrorIn,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ResolveErrorOut:
|
||||||
|
"""Mark every open occurrence of one bug as dealt with."""
|
||||||
|
repo = ObservabilityRepository(session)
|
||||||
|
resolved = await repo.resolve_error_group(
|
||||||
|
fingerprint, note=payload.note, resolved_at=utcnow()
|
||||||
|
)
|
||||||
|
if resolved == 0:
|
||||||
|
raise NotFoundError("موردی برای بستن پیدا نشد؛ ممکن است قبلاً بسته شده باشد.")
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.ERROR_RESOLVE,
|
||||||
|
actor_user_id=owner.id,
|
||||||
|
target_type="error_group",
|
||||||
|
target_id=fingerprint,
|
||||||
|
metadata={"resolved": resolved, "note": payload.note[:200]},
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/errors/groups/{fingerprint}/reopen", response_model=ResolveErrorOut)
|
||||||
|
async def reopen_error_group(
|
||||||
|
fingerprint: str,
|
||||||
|
_: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ResolveErrorOut:
|
||||||
|
"""Undo a resolution — for when the bug comes back."""
|
||||||
|
resolved = await ObservabilityRepository(session).reopen_error_group(fingerprint)
|
||||||
|
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Authentication endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.core.errors import AuthenticationError, ConflictError
|
||||||
|
from app.core.security import create_access_token, hash_password, verify_password
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.identity.models import User
|
||||||
|
from app.infrastructure.repositories.identity import (
|
||||||
|
MembershipRepository,
|
||||||
|
OrganizationRepository,
|
||||||
|
UserRepository,
|
||||||
|
)
|
||||||
|
from app.interfaces.http.deps import client_meta, get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class SignupRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
name: str = Field(default="", max_length=160)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
is_platform_owner: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
user: UserOut
|
||||||
|
organizations: list[OrganizationSummary]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_user_out(user: User) -> UserOut:
|
||||||
|
return UserOut(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.display_name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
is_platform_owner=user.is_platform_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def signup(
|
||||||
|
payload: SignupRequest,
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> TokenResponse:
|
||||||
|
users = UserRepository(session)
|
||||||
|
if await users.get_by_email(payload.email) is not None:
|
||||||
|
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
|
||||||
|
|
||||||
|
user = await users.create(
|
||||||
|
User(
|
||||||
|
email=payload.email,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
name=payload.name.strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.SIGNUP,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> TokenResponse:
|
||||||
|
user = await UserRepository(session).get_by_email(payload.email)
|
||||||
|
if user is None or not verify_password(payload.password, user.password_hash):
|
||||||
|
raise AuthenticationError("ایمیل یا رمز عبور نادرست است.")
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthenticationError("حساب کاربری غیرفعال است.")
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.LOGIN,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
"""JWTs are stateless; logout is an audit marker today and a token-revocation
|
||||||
|
hook once a denylist (Redis) is wired in."""
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.LOGOUT,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
async def me(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> MeResponse:
|
||||||
|
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||||
|
return MeResponse(
|
||||||
|
user=_to_user_out(user),
|
||||||
|
organizations=[
|
||||||
|
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
|
||||||
|
for org, role in orgs
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/memberships")
|
||||||
|
async def my_memberships(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||||
|
memberships = MembershipRepository(session)
|
||||||
|
out: list[dict] = []
|
||||||
|
for org, role in orgs:
|
||||||
|
membership = await memberships.get(org.id, user.id)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"organization_id": org.id,
|
||||||
|
"organization_name": org.name,
|
||||||
|
"role": role.value,
|
||||||
|
"status": membership.status.value if membership else "unknown",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
|
||||||
|
|
||||||
|
Reading billing requires ``billing.view``; changing it requires
|
||||||
|
``billing.manage``. A tenant can therefore never see — or move — another
|
||||||
|
organization's money.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.application.services.billing_service import BillingService
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
|
||||||
|
from app.interfaces.http.deps import (
|
||||||
|
OrgContext,
|
||||||
|
client_meta,
|
||||||
|
get_billing_service,
|
||||||
|
get_org_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class UnitAggregateOut(BaseModel):
|
||||||
|
unit: str
|
||||||
|
quantity: float
|
||||||
|
total_price: float
|
||||||
|
|
||||||
|
|
||||||
|
class BillingSummaryOut(BaseModel):
|
||||||
|
balance: float
|
||||||
|
currency: str
|
||||||
|
low_balance: bool
|
||||||
|
low_balance_threshold: float
|
||||||
|
total_spend: float
|
||||||
|
by_unit: list[UnitAggregateOut]
|
||||||
|
by_employee: dict[str, float]
|
||||||
|
by_project: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class UsageEventOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
unit: str
|
||||||
|
quantity: float
|
||||||
|
unit_price: float
|
||||||
|
total_price: float
|
||||||
|
project_id: str | None
|
||||||
|
employee_id: str | None
|
||||||
|
run_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
amount: float
|
||||||
|
currency: str
|
||||||
|
status: str
|
||||||
|
period_start: datetime | None
|
||||||
|
period_end: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
amount: float
|
||||||
|
currency: str
|
||||||
|
status: str
|
||||||
|
provider: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddCreditRequest(BaseModel):
|
||||||
|
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
|
||||||
|
|
||||||
|
|
||||||
|
class WalletOut(BaseModel):
|
||||||
|
balance: float
|
||||||
|
currency: str
|
||||||
|
low_balance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutRequest(BaseModel):
|
||||||
|
amount: float = Field(gt=0)
|
||||||
|
success_url: str = Field(default="", max_length=512)
|
||||||
|
cancel_url: str = Field(default="", max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutResponse(BaseModel):
|
||||||
|
payment_id: str
|
||||||
|
checkout_url: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
|
class SettlePaymentRequest(BaseModel):
|
||||||
|
succeeded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=BillingSummaryOut)
|
||||||
|
async def billing_summary(
|
||||||
|
since: datetime | None = Query(default=None),
|
||||||
|
until: datetime | None = Query(default=None),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
) -> BillingSummaryOut:
|
||||||
|
context.require(CAN_VIEW_BILLING)
|
||||||
|
summary = await service.summary(
|
||||||
|
organization_id=context.org_id, since=since, until=until
|
||||||
|
)
|
||||||
|
return BillingSummaryOut(
|
||||||
|
balance=summary["balance"],
|
||||||
|
currency=summary["currency"],
|
||||||
|
low_balance=summary["low_balance"],
|
||||||
|
low_balance_threshold=summary["low_balance_threshold"],
|
||||||
|
total_spend=summary["total_spend"],
|
||||||
|
by_unit=[
|
||||||
|
UnitAggregateOut(
|
||||||
|
unit=str(a.unit), quantity=a.quantity, total_price=a.total_price
|
||||||
|
)
|
||||||
|
for a in summary["by_unit"]
|
||||||
|
],
|
||||||
|
by_employee=summary["by_employee"],
|
||||||
|
by_project=summary["by_project"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage-events", response_model=list[UsageEventOut])
|
||||||
|
async def list_usage_events(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
) -> list[UsageEventOut]:
|
||||||
|
context.require(CAN_VIEW_BILLING)
|
||||||
|
rows = await service.list_usage_events(
|
||||||
|
organization_id=context.org_id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
UsageEventOut(
|
||||||
|
id=r.id, unit=r.unit, quantity=r.quantity, unit_price=r.unit_price,
|
||||||
|
total_price=r.total_price, project_id=r.project_id,
|
||||||
|
employee_id=r.employee_id, run_id=r.run_id, created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invoices", response_model=list[InvoiceOut])
|
||||||
|
async def list_invoices(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
) -> list[InvoiceOut]:
|
||||||
|
context.require(CAN_VIEW_BILLING)
|
||||||
|
invoices = await service.list_invoices(context.org_id)
|
||||||
|
return [
|
||||||
|
InvoiceOut(
|
||||||
|
id=i.id, amount=i.amount, currency=i.currency, status=str(i.status),
|
||||||
|
period_start=i.period_start, period_end=i.period_end, created_at=i.created_at,
|
||||||
|
)
|
||||||
|
for i in invoices
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/payments", response_model=list[PaymentOut])
|
||||||
|
async def list_payments(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
) -> list[PaymentOut]:
|
||||||
|
context.require(CAN_VIEW_BILLING)
|
||||||
|
wallet = await service.wallet_for(context.org_id)
|
||||||
|
payments = await service.list_payments(context.org_id)
|
||||||
|
return [
|
||||||
|
PaymentOut(
|
||||||
|
id=p.id, amount=p.amount, currency=wallet.currency, status=str(p.status),
|
||||||
|
provider=p.provider, created_at=p.created_at,
|
||||||
|
)
|
||||||
|
for p in payments
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Writes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add-credit", response_model=WalletOut)
|
||||||
|
async def add_credit(
|
||||||
|
payload: AddCreditRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> WalletOut:
|
||||||
|
# A negative delta is a platform-owner-only clawback.
|
||||||
|
if payload.amount < 0:
|
||||||
|
if not context.user.is_platform_owner:
|
||||||
|
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
|
||||||
|
from app.core.errors import PermissionDeniedError
|
||||||
|
|
||||||
|
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
||||||
|
else:
|
||||||
|
context.require(CAN_MANAGE_BILLING)
|
||||||
|
|
||||||
|
wallet = await service.add_credit(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
amount=payload.amount,
|
||||||
|
allow_negative=context.user.is_platform_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="credit_wallet",
|
||||||
|
target_id=wallet.id,
|
||||||
|
metadata={"amount": payload.amount, "balance": wallet.balance},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return WalletOut(
|
||||||
|
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkout", response_model=CheckoutResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def start_checkout(
|
||||||
|
payload: CheckoutRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
) -> CheckoutResponse:
|
||||||
|
context.require(CAN_MANAGE_BILLING)
|
||||||
|
payment, url = await service.start_checkout(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
amount=payload.amount,
|
||||||
|
success_url=payload.success_url,
|
||||||
|
cancel_url=payload.cancel_url,
|
||||||
|
)
|
||||||
|
return CheckoutResponse(
|
||||||
|
payment_id=payment.id, checkout_url=url, amount=payment.amount
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/payments/{payment_id}/settle", response_model=PaymentOut)
|
||||||
|
async def settle_payment(
|
||||||
|
payment_id: str,
|
||||||
|
payload: SettlePaymentRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: BillingService = Depends(get_billing_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> PaymentOut:
|
||||||
|
"""Finalise a payment.
|
||||||
|
|
||||||
|
In production the payment provider calls a webhook; this endpoint is the
|
||||||
|
manual/dev equivalent. It is the only path that converts money into credit.
|
||||||
|
"""
|
||||||
|
context.require(CAN_MANAGE_BILLING)
|
||||||
|
payment = await service.settle_payment(
|
||||||
|
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
|
||||||
|
)
|
||||||
|
wallet = await service.wallet_for(context.org_id)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.BILLING_PAYMENT, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="payment", target_id=payment.id,
|
||||||
|
metadata={"status": str(payment.status), "amount": payment.amount},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return PaymentOut(
|
||||||
|
id=payment.id, amount=payment.amount, currency=wallet.currency,
|
||||||
|
status=str(payment.status), provider=payment.provider,
|
||||||
|
created_at=payment.created_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Health and readiness endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.infrastructure.adapters.registry import get_model_provider
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
"""Liveness. Must stay dependency-free so it answers during a DB outage."""
|
||||||
|
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready")
|
||||||
|
async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
|
||||||
|
"""Readiness: verifies the database and reports which adapters are active."""
|
||||||
|
checks: dict[str, object] = {}
|
||||||
|
try:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||||
|
checks["database"] = f"error: {exc.__class__.__name__}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_health = await get_model_provider().health()
|
||||||
|
checks["model_provider"] = "ok" if model_health.ok else f"degraded: {model_health.detail}"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
checks["model_provider"] = f"error: {exc.__class__.__name__}"
|
||||||
|
|
||||||
|
checks["adapters"] = {
|
||||||
|
"ai": settings.ai_default_provider,
|
||||||
|
"git": settings.git_provider,
|
||||||
|
"domain_router": settings.domain_router,
|
||||||
|
"sandbox": settings.sandbox_provider,
|
||||||
|
"payment": settings.payment_provider,
|
||||||
|
}
|
||||||
|
healthy = checks.get("database") == "ok"
|
||||||
|
return {"status": "ok" if healthy else "degraded", "checks": checks}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Knowledge Base endpoints.
|
||||||
|
|
||||||
|
Ingestion and deletion require ``employees.manage`` (the same trust level as
|
||||||
|
editing an employee's prompt); search only requires ``tasks.run``, because
|
||||||
|
reading your own organization's knowledge is part of doing work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.application.services.knowledge_service import KnowledgeService
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
|
||||||
|
from app.interfaces.http.deps import (
|
||||||
|
OrgContext,
|
||||||
|
client_meta,
|
||||||
|
get_knowledge_service,
|
||||||
|
get_org_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations/{org_id}/knowledge", tags=["knowledge"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSourceOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
project_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
chunk_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTextSourceRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=255)
|
||||||
|
content: str = Field(min_length=1, max_length=400_000)
|
||||||
|
title: str = Field(default="", max_length=255)
|
||||||
|
project_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingJobOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSourceDetailOut(KnowledgeSourceOut):
|
||||||
|
documents: list[DocumentOut]
|
||||||
|
job: EmbeddingJobOut | None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchHitOut(BaseModel):
|
||||||
|
chunk_id: str
|
||||||
|
content: str
|
||||||
|
index: int
|
||||||
|
document_title: str
|
||||||
|
source_id: str
|
||||||
|
source_name: str
|
||||||
|
project_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRetrievalPolicyRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=120)
|
||||||
|
scope: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalPolicyOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
scope_json: dict
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sources
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sources", response_model=KnowledgeSourceOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_text_source(
|
||||||
|
payload: CreateTextSourceRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> KnowledgeSourceOut:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
|
||||||
|
source, chunk_count = await service.create_text_source(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
name=payload.name,
|
||||||
|
content=payload.content,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
title=payload.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action="knowledge.source_create", organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="knowledge_source",
|
||||||
|
target_id=source.id, metadata={"name": source.name, "chunks": chunk_count},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return KnowledgeSourceOut(
|
||||||
|
id=source.id, name=source.name, kind=str(source.kind),
|
||||||
|
project_id=source.project_id, created_at=source.created_at,
|
||||||
|
chunk_count=chunk_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources", response_model=list[KnowledgeSourceOut])
|
||||||
|
async def list_sources(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> list[KnowledgeSourceOut]:
|
||||||
|
sources = await service.list_sources(context.org_id, limit=limit, offset=offset)
|
||||||
|
# One aggregate query for every source's chunk count. Asking per source
|
||||||
|
# inside the loop was an N+1: 100 sources meant 101 round trips.
|
||||||
|
chunk_counts = await service.chunk_counts([s.id for s in sources])
|
||||||
|
return [
|
||||||
|
KnowledgeSourceOut(
|
||||||
|
id=source.id, name=source.name, kind=str(source.kind),
|
||||||
|
project_id=source.project_id, created_at=source.created_at,
|
||||||
|
chunk_count=chunk_counts.get(source.id, 0),
|
||||||
|
)
|
||||||
|
for source in sources
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources/{source_id}", response_model=KnowledgeSourceDetailOut)
|
||||||
|
async def get_source(
|
||||||
|
source_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
) -> KnowledgeSourceDetailOut:
|
||||||
|
source = await service.get_source_or_404(
|
||||||
|
organization_id=context.org_id, source_id=source_id
|
||||||
|
)
|
||||||
|
detail = await service.source_detail(source_id=source.id)
|
||||||
|
job = detail["job"]
|
||||||
|
return KnowledgeSourceDetailOut(
|
||||||
|
id=source.id, name=source.name, kind=str(source.kind),
|
||||||
|
project_id=source.project_id, created_at=source.created_at,
|
||||||
|
chunk_count=detail["chunk_count"],
|
||||||
|
documents=[
|
||||||
|
DocumentOut(
|
||||||
|
id=d.id, title=d.title, content=d.content, created_at=d.created_at
|
||||||
|
)
|
||||||
|
for d in detail["documents"]
|
||||||
|
],
|
||||||
|
job=(
|
||||||
|
EmbeddingJobOut(id=job.id, status=str(job.status), error=job.error)
|
||||||
|
if job is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_source(
|
||||||
|
source_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
await service.delete_source(organization_id=context.org_id, source_id=source_id)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action="knowledge.source_delete", organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="knowledge_source",
|
||||||
|
target_id=source_id, ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[SearchHitOut])
|
||||||
|
async def search_knowledge(
|
||||||
|
q: str = Query(min_length=1, max_length=300),
|
||||||
|
project_id: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=5, ge=1, le=50),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
) -> list[SearchHitOut]:
|
||||||
|
context.require(CAN_RUN_TASKS)
|
||||||
|
hits = await service.search(
|
||||||
|
organization_id=context.org_id, query=q, project_id=project_id, limit=limit
|
||||||
|
)
|
||||||
|
return [SearchHitOut(**hit) for hit in hits]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Retrieval policies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/retrieval-policies", response_model=list[RetrievalPolicyOut])
|
||||||
|
async def list_retrieval_policies(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
) -> list[RetrievalPolicyOut]:
|
||||||
|
policies = await service.list_policies(context.org_id)
|
||||||
|
return [
|
||||||
|
RetrievalPolicyOut(id=p.id, name=p.name, scope_json=dict(p.scope_json))
|
||||||
|
for p in policies
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/retrieval-policies",
|
||||||
|
response_model=RetrievalPolicyOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_retrieval_policy(
|
||||||
|
payload: CreateRetrievalPolicyRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: KnowledgeService = Depends(get_knowledge_service),
|
||||||
|
) -> RetrievalPolicyOut:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
policy = await service.create_policy(
|
||||||
|
organization_id=context.org_id, name=payload.name, scope=payload.scope
|
||||||
|
)
|
||||||
|
return RetrievalPolicyOut(
|
||||||
|
id=policy.id, name=policy.name, scope_json=dict(policy.scope_json)
|
||||||
|
)
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""Organization, membership and invite endpoints.
|
||||||
|
|
||||||
|
Every route below the organization prefix resolves its tenant through
|
||||||
|
``get_org_context``, so a caller can never read or mutate another tenant's data
|
||||||
|
by guessing an id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||||
|
from app.core.security import generate_token, hash_token
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.base import ensure_unique_slug, slugify
|
||||||
|
from app.domain.identity.models import (
|
||||||
|
CAN_MANAGE_MEMBERS,
|
||||||
|
CAN_VIEW_AUDIT,
|
||||||
|
ROLE_RANK,
|
||||||
|
Invite,
|
||||||
|
Membership,
|
||||||
|
MembershipStatus,
|
||||||
|
Organization,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import CreditWalletRow
|
||||||
|
from app.infrastructure.repositories.audit import AuditRepository
|
||||||
|
from app.infrastructure.repositories.identity import (
|
||||||
|
InviteRepository,
|
||||||
|
MembershipRepository,
|
||||||
|
OrganizationRepository,
|
||||||
|
UserRepository,
|
||||||
|
)
|
||||||
|
from app.interfaces.http.deps import OrgContext, client_meta, get_current_user, get_org_context
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||||
|
|
||||||
|
INVITE_TTL_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrganizationRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=160)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
owner_user_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationWithRole(OrganizationOut):
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class MemberOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
email: str = ""
|
||||||
|
name: str = ""
|
||||||
|
role: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
role: Role = Role.MEMBER
|
||||||
|
|
||||||
|
|
||||||
|
class InviteOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
expires_at: datetime
|
||||||
|
invite_token: str
|
||||||
|
invite_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMemberRequest(BaseModel):
|
||||||
|
role: Role | None = None
|
||||||
|
status: MembershipStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
action: str
|
||||||
|
actor_user_id: str | None
|
||||||
|
target_type: str
|
||||||
|
target_id: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _org_out(org: Organization) -> OrganizationOut:
|
||||||
|
return OrganizationOut(
|
||||||
|
id=org.id, name=org.name, slug=org.slug, owner_user_id=org.owner_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=OrganizationWithRole, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_organization(
|
||||||
|
payload: CreateOrganizationRequest,
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> OrganizationWithRole:
|
||||||
|
orgs = OrganizationRepository(session)
|
||||||
|
base = slugify(payload.name)
|
||||||
|
slug = ensure_unique_slug(base, await orgs.list_all_slugs())
|
||||||
|
|
||||||
|
org = await orgs.create(
|
||||||
|
Organization(name=payload.name.strip(), slug=slug, owner_user_id=user.id)
|
||||||
|
)
|
||||||
|
await MembershipRepository(session).add(
|
||||||
|
Membership(
|
||||||
|
organization_id=org.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=Role.OWNER,
|
||||||
|
status=MembershipStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Every organization gets a wallet up front so billing views are never empty.
|
||||||
|
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.ORG_CREATE,
|
||||||
|
organization_id=org.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
target_type="organization",
|
||||||
|
target_id=org.id,
|
||||||
|
metadata={"name": org.name, "slug": org.slug},
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return OrganizationWithRole(
|
||||||
|
id=org.id, name=org.name, slug=org.slug,
|
||||||
|
owner_user_id=org.owner_user_id, role=Role.OWNER.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[OrganizationWithRole])
|
||||||
|
async def list_organizations(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[OrganizationWithRole]:
|
||||||
|
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||||
|
return [
|
||||||
|
OrganizationWithRole(
|
||||||
|
id=org.id, name=org.name, slug=org.slug,
|
||||||
|
owner_user_id=org.owner_user_id, role=role.value,
|
||||||
|
)
|
||||||
|
for org, role in orgs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}", response_model=OrganizationWithRole)
|
||||||
|
async def get_organization(context: OrgContext = Depends(get_org_context)) -> OrganizationWithRole:
|
||||||
|
return OrganizationWithRole(
|
||||||
|
id=context.organization.id,
|
||||||
|
name=context.organization.name,
|
||||||
|
slug=context.organization.slug,
|
||||||
|
owner_user_id=context.organization.owner_user_id,
|
||||||
|
role=context.membership.role.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}/members", response_model=list[MemberOut])
|
||||||
|
async def list_members(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[MemberOut]:
|
||||||
|
memberships = await MembershipRepository(session).list_for_org(context.org_id)
|
||||||
|
users = UserRepository(session)
|
||||||
|
out: list[MemberOut] = []
|
||||||
|
for membership in memberships:
|
||||||
|
member_user = await users.get(membership.user_id)
|
||||||
|
out.append(
|
||||||
|
MemberOut(
|
||||||
|
id=membership.id,
|
||||||
|
user_id=membership.user_id,
|
||||||
|
email=member_user.email if member_user else "",
|
||||||
|
name=member_user.display_name if member_user else "",
|
||||||
|
role=membership.role.value,
|
||||||
|
status=membership.status.value,
|
||||||
|
created_at=membership.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{org_id}/invites", response_model=InviteOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_invite(
|
||||||
|
payload: InviteRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> InviteOut:
|
||||||
|
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||||
|
raise PermissionDeniedError("دعوت عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||||
|
if ROLE_RANK[payload.role] >= ROLE_RANK[Role.OWNER]:
|
||||||
|
raise ValidationError("نقش owner از طریق دعوت قابل واگذاری نیست.")
|
||||||
|
|
||||||
|
raw_token = generate_token()
|
||||||
|
invite = await InviteRepository(session).create(
|
||||||
|
Invite(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
email=str(payload.email),
|
||||||
|
role=payload.role,
|
||||||
|
token_hash=hash_token(raw_token),
|
||||||
|
invited_by_user_id=context.user.id,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(days=INVITE_TTL_DAYS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.MEMBER_INVITE,
|
||||||
|
organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id,
|
||||||
|
target_type="invite",
|
||||||
|
target_id=invite.id,
|
||||||
|
metadata={"email": invite.email, "role": invite.role.value},
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
return InviteOut(
|
||||||
|
id=invite.id,
|
||||||
|
email=invite.email,
|
||||||
|
role=invite.role.value,
|
||||||
|
expires_at=invite.expires_at,
|
||||||
|
invite_token=raw_token,
|
||||||
|
invite_url=f"/accept-invite?token={raw_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{org_id}/members/{member_id}", response_model=MemberOut)
|
||||||
|
async def update_member(
|
||||||
|
member_id: str,
|
||||||
|
payload: UpdateMemberRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> MemberOut:
|
||||||
|
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||||
|
raise PermissionDeniedError("تغییر نقش عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||||
|
|
||||||
|
memberships = MembershipRepository(session)
|
||||||
|
target = await memberships.get_by_id(member_id)
|
||||||
|
if target is None or target.organization_id != context.org_id:
|
||||||
|
raise NotFoundError("عضو یافت نشد.")
|
||||||
|
|
||||||
|
# An admin may not outrank themselves or promote anyone to owner.
|
||||||
|
if target.role == Role.OWNER and context.user.id != target.user_id:
|
||||||
|
raise PermissionDeniedError("نقش owner فقط توسط خودش قابل تغییر است.")
|
||||||
|
if payload.role is not None and ROLE_RANK[payload.role] > ROLE_RANK[context.membership.role]:
|
||||||
|
raise PermissionDeniedError("نمیتوانید نقشی بالاتر از نقش خود واگذار کنید.")
|
||||||
|
|
||||||
|
if payload.role is not None:
|
||||||
|
if target.role == Role.OWNER and await memberships.count_owners(context.org_id) <= 1:
|
||||||
|
raise ValidationError("آخرین owner سازمان را نمیتوان تنزل داد.")
|
||||||
|
await memberships.set_role(member_id, payload.role)
|
||||||
|
if payload.status is not None:
|
||||||
|
await memberships.set_status(member_id, payload.status)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.MEMBER_ROLE_CHANGE,
|
||||||
|
organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id,
|
||||||
|
target_type="membership",
|
||||||
|
target_id=member_id,
|
||||||
|
metadata={
|
||||||
|
"role": payload.role.value if payload.role else None,
|
||||||
|
"status": payload.status.value if payload.status else None,
|
||||||
|
},
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await memberships.get_by_id(member_id)
|
||||||
|
assert updated is not None
|
||||||
|
member_user = await UserRepository(session).get(updated.user_id)
|
||||||
|
return MemberOut(
|
||||||
|
id=updated.id,
|
||||||
|
user_id=updated.user_id,
|
||||||
|
email=member_user.email if member_user else "",
|
||||||
|
name=member_user.display_name if member_user else "",
|
||||||
|
role=updated.role.value,
|
||||||
|
status=updated.status.value,
|
||||||
|
created_at=updated.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{org_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_member(
|
||||||
|
member_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||||
|
raise PermissionDeniedError("حذف عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||||
|
|
||||||
|
memberships = MembershipRepository(session)
|
||||||
|
target = await memberships.get_by_id(member_id)
|
||||||
|
if target is None or target.organization_id != context.org_id:
|
||||||
|
raise NotFoundError("عضو یافت نشد.")
|
||||||
|
if target.role == Role.OWNER:
|
||||||
|
raise ValidationError("owner سازمان را نمیتوان حذف کرد؛ ابتدا نقش را تغییر دهید.")
|
||||||
|
|
||||||
|
await memberships.remove(member_id)
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session,
|
||||||
|
action=AuditAction.MEMBER_REMOVE,
|
||||||
|
organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id,
|
||||||
|
target_type="membership",
|
||||||
|
target_id=member_id,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}/audit", response_model=list[AuditEventOut])
|
||||||
|
async def list_audit(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[AuditEventOut]:
|
||||||
|
if not context.membership.has(CAN_VIEW_AUDIT):
|
||||||
|
raise PermissionDeniedError("مشاهدهٔ audit log نیازمند دسترسی مدیر است.")
|
||||||
|
events = await AuditRepository(session).list_for_organization(
|
||||||
|
organization_id=context.org_id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
AuditEventOut(
|
||||||
|
id=e.id, action=e.action, actor_user_id=e.actor_user_id,
|
||||||
|
target_type=e.target_type, target_id=e.target_id, created_at=e.created_at,
|
||||||
|
)
|
||||||
|
for e in events
|
||||||
|
]
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""Project, repository and preview-domain endpoints.
|
||||||
|
|
||||||
|
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
|
||||||
|
context is resolved by ``get_org_context`` before any handler body runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.application.services.project_service import ProjectService
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.core.errors import NotFoundError
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.identity.models import CAN_MANAGE_PROJECTS
|
||||||
|
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/organizations/{org_id}/projects", tags=["projects"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
organization_id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
description: str
|
||||||
|
default_branch: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProjectRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=160)
|
||||||
|
description: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class RepoOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
provider: str
|
||||||
|
owner: str
|
||||||
|
repo_name: str
|
||||||
|
clone_url: str
|
||||||
|
web_url: str
|
||||||
|
default_branch: str
|
||||||
|
private: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class RepoStatusOut(BaseModel):
|
||||||
|
exists: bool
|
||||||
|
private: bool
|
||||||
|
default_branch: str
|
||||||
|
branches: list[str]
|
||||||
|
open_pull_requests: int
|
||||||
|
|
||||||
|
|
||||||
|
class CreateBranchRequest(BaseModel):
|
||||||
|
branch_name: str = Field(min_length=1, max_length=120)
|
||||||
|
from_branch: str = Field(default="main", max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class BranchOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
sha: str
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPullRequestRequest(BaseModel):
|
||||||
|
head: str = Field(min_length=1, max_length=120)
|
||||||
|
base: str = Field(default="main", max_length=120)
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
body: str = Field(default="", max_length=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
class PullRequestOut(BaseModel):
|
||||||
|
number: int
|
||||||
|
url: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewDomainOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
status: str
|
||||||
|
protected: bool
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePreviewDomainRequest(BaseModel):
|
||||||
|
subdomain: str = Field(min_length=3, max_length=40)
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
|
||||||
|
|
||||||
|
class AllocatePortRequest(BaseModel):
|
||||||
|
service_name: str = Field(default="web", max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class PortOut(BaseModel):
|
||||||
|
port: int
|
||||||
|
service_name: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Projects
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_project(
|
||||||
|
payload: CreateProjectRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ProjectOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
|
||||||
|
project = await service.create_project(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
organization_slug=context.organization.slug,
|
||||||
|
created_by_user_id=context.user.id,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="project", target_id=project.id,
|
||||||
|
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return ProjectOut(
|
||||||
|
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||||
|
slug=project.slug, description=project.description,
|
||||||
|
default_branch=project.default_branch, created_at=project.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ProjectOut])
|
||||||
|
async def list_projects(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> list[ProjectOut]:
|
||||||
|
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
|
||||||
|
return [
|
||||||
|
ProjectOut(
|
||||||
|
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
|
||||||
|
description=p.description, default_branch=p.default_branch,
|
||||||
|
created_at=p.created_at,
|
||||||
|
)
|
||||||
|
for p in projects
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}", response_model=ProjectOut)
|
||||||
|
async def get_project(
|
||||||
|
project_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> ProjectOut:
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
return ProjectOut(
|
||||||
|
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||||
|
slug=project.slug, description=project.description,
|
||||||
|
default_branch=project.default_branch, created_at=project.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Repository
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
|
||||||
|
async def ensure_repo(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> RepoOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
|
||||||
|
repo = await service.ensure_repository(
|
||||||
|
organization_slug=context.organization.slug, project=project
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
|
||||||
|
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return RepoOut(
|
||||||
|
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
|
||||||
|
clone_url=repo.clone_url, web_url=repo.web_url,
|
||||||
|
default_branch=repo.default_branch, private=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/repo", response_model=RepoOut)
|
||||||
|
async def get_repo(
|
||||||
|
project_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> RepoOut:
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
repo = await service.repos.get_for_project(project.id)
|
||||||
|
if repo is None:
|
||||||
|
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
|
||||||
|
# clone_url is a plain https URL; credentials are never included.
|
||||||
|
return RepoOut(
|
||||||
|
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
|
||||||
|
clone_url=repo.clone_url, web_url=repo.web_url,
|
||||||
|
default_branch=repo.default_branch, private=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
|
||||||
|
async def get_repo_status(
|
||||||
|
project_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> RepoStatusOut:
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
status_result = await service.repo_status(project=project)
|
||||||
|
return RepoStatusOut(
|
||||||
|
exists=status_result.exists,
|
||||||
|
private=status_result.private,
|
||||||
|
default_branch=status_result.default_branch,
|
||||||
|
branches=list(status_result.branches),
|
||||||
|
open_pull_requests=status_result.open_pull_requests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
|
||||||
|
async def create_branch(
|
||||||
|
project_id: str,
|
||||||
|
payload: CreateBranchRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> BranchOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
branch = await service.git.create_branch(
|
||||||
|
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
|
||||||
|
)
|
||||||
|
return BranchOut(name=branch.name, sha=branch.sha)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
|
||||||
|
async def open_pull_request(
|
||||||
|
project_id: str,
|
||||||
|
payload: OpenPullRequestRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> PullRequestOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
pr = await service.git.open_pull_request(
|
||||||
|
project_id=project.id, head=payload.head, base=payload.base,
|
||||||
|
title=payload.title, body=payload.body,
|
||||||
|
)
|
||||||
|
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview domains
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/preview/ports", response_model=PortOut)
|
||||||
|
async def allocate_port(
|
||||||
|
project_id: str,
|
||||||
|
payload: AllocatePortRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> PortOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
port = await service.allocate_port(project=project, service_name=payload.service_name)
|
||||||
|
return PortOut(port=port, service_name=payload.service_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{project_id}/preview/domains",
|
||||||
|
response_model=PreviewDomainOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_preview_domain(
|
||||||
|
project_id: str,
|
||||||
|
payload: CreatePreviewDomainRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> PreviewDomainOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
|
||||||
|
record = await service.create_preview_domain(
|
||||||
|
project=project,
|
||||||
|
requested_subdomain=payload.subdomain,
|
||||||
|
created_by_user_id=context.user.id,
|
||||||
|
port=payload.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="preview_domain",
|
||||||
|
target_id=record["id"],
|
||||||
|
metadata={"host": record["host"], "port": record["port"]},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return PreviewDomainOut(
|
||||||
|
id=record["id"], host=record["host"], port=record["port"],
|
||||||
|
status=record["status"], protected=record["protected"], url=record["url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
|
||||||
|
async def list_preview_domains(
|
||||||
|
project_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
) -> list[PreviewDomainOut]:
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
records = await service.list_preview_domains(project=project)
|
||||||
|
return [
|
||||||
|
PreviewDomainOut(
|
||||||
|
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
|
||||||
|
protected=r["protected"], url=r["url"],
|
||||||
|
)
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
async def delete_preview_domain(
|
||||||
|
project_id: str,
|
||||||
|
domain_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: ProjectService = Depends(get_project_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
await service.remove_preview_domain(project=project, domain_id=domain_id)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""Sandbox Gateway endpoints.
|
||||||
|
|
||||||
|
Note what is **absent**: there is no route that accepts a shell command and runs
|
||||||
|
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
|
||||||
|
policy timeout and output cap, and is metered into the usage ledger.
|
||||||
|
|
||||||
|
Running a command requires ``sandbox.use``; tearing a workspace down requires
|
||||||
|
``projects.manage`` because it destroys work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.application.services.sandbox_service import SandboxService
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
|
||||||
|
from app.domain.sandbox.models import NetworkPolicy
|
||||||
|
from app.interfaces.http.deps import (
|
||||||
|
OrgContext,
|
||||||
|
client_meta,
|
||||||
|
get_org_context,
|
||||||
|
get_sandbox_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxTemplateOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
provider: str
|
||||||
|
image: str
|
||||||
|
description: str
|
||||||
|
default_cpu: float
|
||||||
|
default_memory_mb: int
|
||||||
|
default_disk_mb: int
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
project_id: str
|
||||||
|
template_id: str
|
||||||
|
provider: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWorkspaceRequest(BaseModel):
|
||||||
|
project_id: str
|
||||||
|
template_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SessionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
workspace_id: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime
|
||||||
|
last_active_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StartSessionRequest(BaseModel):
|
||||||
|
workspace_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
command: str
|
||||||
|
exit_code: int | None
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
duration_ms: int
|
||||||
|
truncated: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RunCommandRequest(BaseModel):
|
||||||
|
command: str = Field(min_length=1, max_length=4_000)
|
||||||
|
|
||||||
|
|
||||||
|
class FileReadOut(BaseModel):
|
||||||
|
path: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReadFileRequest(BaseModel):
|
||||||
|
path: str = Field(min_length=1, max_length=1_000)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteFileRequest(BaseModel):
|
||||||
|
path: str = Field(min_length=1, max_length=1_000)
|
||||||
|
content: str = Field(max_length=1_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSnapshotRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewUrlOut(BaseModel):
|
||||||
|
port: int
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceLimitOut(BaseModel):
|
||||||
|
cpu: float
|
||||||
|
memory_mb: int
|
||||||
|
disk_mb: int
|
||||||
|
timeout_seconds: int
|
||||||
|
max_output_bytes: int
|
||||||
|
network_policy: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetLimitsRequest(BaseModel):
|
||||||
|
scope: str = "organization"
|
||||||
|
scope_id: str | None = None
|
||||||
|
cpu: float = Field(default=1.0, gt=0, le=16)
|
||||||
|
memory_mb: int = Field(default=1024, ge=128, le=65_536)
|
||||||
|
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
|
||||||
|
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
||||||
|
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
|
||||||
|
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Templates & limits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=list[SandboxTemplateOut])
|
||||||
|
async def list_templates(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> list[SandboxTemplateOut]:
|
||||||
|
templates = await service.list_templates()
|
||||||
|
return [
|
||||||
|
SandboxTemplateOut(
|
||||||
|
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
|
||||||
|
description=t.description, default_cpu=t.default_cpu,
|
||||||
|
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
|
||||||
|
)
|
||||||
|
for t in templates
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/limits", response_model=ResourceLimitOut)
|
||||||
|
async def get_limits(
|
||||||
|
project_id: str | None = Query(default=None),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> ResourceLimitOut:
|
||||||
|
limit = await service.effective_limits(
|
||||||
|
organization_id=context.org_id, project_id=project_id
|
||||||
|
)
|
||||||
|
return ResourceLimitOut(
|
||||||
|
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||||
|
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
||||||
|
network_policy=str(limit.network_policy),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/limits", response_model=ResourceLimitOut)
|
||||||
|
async def set_limits(
|
||||||
|
payload: SetLimitsRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> ResourceLimitOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
limit = await service.set_limits(
|
||||||
|
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
||||||
|
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
|
||||||
|
timeout_seconds=payload.timeout_seconds,
|
||||||
|
max_output_bytes=payload.max_output_bytes,
|
||||||
|
network_policy=payload.network_policy,
|
||||||
|
)
|
||||||
|
return ResourceLimitOut(
|
||||||
|
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||||
|
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
||||||
|
network_policy=str(limit.network_policy),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Workspaces
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_workspace(
|
||||||
|
payload: CreateWorkspaceRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> WorkspaceOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
|
||||||
|
workspace = await service.create_workspace(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
template_id=payload.template_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action="sandbox.workspace_create", organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
|
||||||
|
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return WorkspaceOut(
|
||||||
|
id=workspace.id, project_id=workspace.project_id,
|
||||||
|
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||||
|
status=str(workspace.status), created_at=workspace.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workspaces", response_model=list[WorkspaceOut])
|
||||||
|
async def list_workspaces(
|
||||||
|
project_id: str = Query(min_length=1),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> list[WorkspaceOut]:
|
||||||
|
workspaces = await service.list_workspaces(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
project_id=project_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
WorkspaceOut(
|
||||||
|
id=w.id, project_id=w.project_id, template_id=w.template_id,
|
||||||
|
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
|
||||||
|
)
|
||||||
|
for w in workspaces
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
|
||||||
|
async def destroy_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> WorkspaceOut:
|
||||||
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
workspace = await service.destroy_workspace(
|
||||||
|
organization_id=context.org_id, workspace_id=workspace_id
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="workspace",
|
||||||
|
target_id=workspace.id, ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return WorkspaceOut(
|
||||||
|
id=workspace.id, project_id=workspace.project_id,
|
||||||
|
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||||
|
status=str(workspace.status), created_at=workspace.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def start_session(
|
||||||
|
payload: StartSessionRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> SessionOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
sandbox_session = await service.start_session(
|
||||||
|
organization_id=context.org_id, workspace_id=payload.workspace_id
|
||||||
|
)
|
||||||
|
return SessionOut(
|
||||||
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||||
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||||
|
last_active_at=sandbox_session.last_active_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
|
||||||
|
async def stop_session(
|
||||||
|
session_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> SessionOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
sandbox_session = await service.stop_session(
|
||||||
|
organization_id=context.org_id, session_id=session_id
|
||||||
|
)
|
||||||
|
return SessionOut(
|
||||||
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||||
|
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||||
|
last_active_at=sandbox_session.last_active_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Commands & files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
|
||||||
|
async def run_command(
|
||||||
|
session_id: str,
|
||||||
|
payload: RunCommandRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CommandRunOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
|
||||||
|
run, _result = await service.run_command(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
session_id=session_id,
|
||||||
|
command=payload.command,
|
||||||
|
user_id=context.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="sandbox_session",
|
||||||
|
target_id=session_id,
|
||||||
|
# The command itself is recorded, but never a secret from the environment.
|
||||||
|
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return CommandRunOut(
|
||||||
|
id=run.id, command=run.command, exit_code=run.exit_code,
|
||||||
|
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
|
||||||
|
truncated=run.truncated, created_at=run.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
|
||||||
|
async def list_commands(
|
||||||
|
session_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> list[CommandRunOut]:
|
||||||
|
await service.get_session_or_404(
|
||||||
|
organization_id=context.org_id, session_id=session_id
|
||||||
|
)
|
||||||
|
runs = await service.list_commands(session_id=session_id)
|
||||||
|
return [
|
||||||
|
CommandRunOut(
|
||||||
|
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
|
||||||
|
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
|
||||||
|
created_at=r.created_at,
|
||||||
|
)
|
||||||
|
for r in runs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
|
||||||
|
async def read_file(
|
||||||
|
session_id: str,
|
||||||
|
payload: ReadFileRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> FileReadOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
content = await service.read_file(
|
||||||
|
organization_id=context.org_id, session_id=session_id, path=payload.path
|
||||||
|
)
|
||||||
|
return FileReadOut(path=payload.path, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def write_file(
|
||||||
|
session_id: str,
|
||||||
|
payload: WriteFileRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> None:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
await service.write_file(
|
||||||
|
organization_id=context.org_id, session_id=session_id,
|
||||||
|
path=payload.path, content=payload.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snapshots & preview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/{session_id}/snapshots",
|
||||||
|
response_model=SnapshotOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_snapshot(
|
||||||
|
session_id: str,
|
||||||
|
payload: CreateSnapshotRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> SnapshotOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
snapshot = await service.snapshot(
|
||||||
|
organization_id=context.org_id, session_id=session_id, name=payload.name
|
||||||
|
)
|
||||||
|
return SnapshotOut(
|
||||||
|
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
|
||||||
|
async def get_preview_url(
|
||||||
|
session_id: str,
|
||||||
|
port: int = Query(ge=1, le=65535),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
|
) -> PreviewUrlOut:
|
||||||
|
context.require(CAN_USE_SANDBOX)
|
||||||
|
preview = await service.get_preview_url(
|
||||||
|
organization_id=context.org_id, session_id=session_id, port=port
|
||||||
|
)
|
||||||
|
return PreviewUrlOut(port=preview.port, url=preview.url)
|
||||||
@@ -0,0 +1,803 @@
|
|||||||
|
"""AI Workforce, Manager Chat, Tasks and Runs.
|
||||||
|
|
||||||
|
Two things are load-bearing in this module:
|
||||||
|
|
||||||
|
* **Model identity never leaves.** ``EmployeeOut`` and ``RunOut`` carry a routing
|
||||||
|
policy id at most. There is no field capable of holding a model or provider
|
||||||
|
name, so a future refactor cannot accidentally leak one.
|
||||||
|
* **Every route resolves its tenant** through ``get_org_context``, and every
|
||||||
|
nested id (employee, thread, task, run) is re-scoped to that tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.application.services.workforce_service import WorkforceService
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
|
||||||
|
from app.domain.workforce.models import (
|
||||||
|
EmployeeStatus,
|
||||||
|
RoleKey,
|
||||||
|
TaskStatus,
|
||||||
|
)
|
||||||
|
from app.interfaces.http.deps import (
|
||||||
|
OrgContext,
|
||||||
|
client_meta,
|
||||||
|
get_org_context,
|
||||||
|
get_workforce_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/organizations/{org_id}", tags=["workforce"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTemplateOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
role_key: str
|
||||||
|
description: str
|
||||||
|
default_tools_json: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingChoiceOut(BaseModel):
|
||||||
|
"""A routing policy as a *customer* may see it.
|
||||||
|
|
||||||
|
Name and description only. Deliberately omits ``mode`` and every field that
|
||||||
|
could hint at which model sits behind the choice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSpecOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
requires_approval: bool
|
||||||
|
destructive: bool
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetToolPermissionRequest(BaseModel):
|
||||||
|
allowed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DecideApprovalRequest(BaseModel):
|
||||||
|
approve: bool
|
||||||
|
note: str = Field(default="", max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
organization_id: str
|
||||||
|
project_id: str | None
|
||||||
|
template_id: str | None
|
||||||
|
name: str
|
||||||
|
role_key: str
|
||||||
|
status: str
|
||||||
|
# A routing policy id — never a model or provider name.
|
||||||
|
routing_policy_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeDetailOut(EmployeeOut):
|
||||||
|
prompt_versions: list[PromptVersionOut]
|
||||||
|
tool_permissions: list[ToolPermissionOut]
|
||||||
|
open_task_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEmployeeRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=160)
|
||||||
|
role_key: RoleKey
|
||||||
|
template_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
routing_policy_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PromptVersionOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
version: int
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ToolPermissionOut(BaseModel):
|
||||||
|
tool_key: str
|
||||||
|
allowed: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ``EmployeeDetailOut`` refers to the two models above by name. With
|
||||||
|
# ``from __future__ import annotations`` every annotation is a string, so Pydantic
|
||||||
|
# must be told to resolve them once the names exist.
|
||||||
|
EmployeeDetailOut.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEmployeeRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=2, max_length=160)
|
||||||
|
status: EmployeeStatus | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
routing_policy_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePromptVersionRequest(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=20_000)
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
organization_id: str
|
||||||
|
project_id: str | None
|
||||||
|
title: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
thread_id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
employee_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadDetailOut(ThreadOut):
|
||||||
|
messages: list[MessageOut]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateThreadRequest(BaseModel):
|
||||||
|
title: str = Field(default="", max_length=255)
|
||||||
|
project_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PostMessageRequest(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=20_000)
|
||||||
|
employee_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PostMessageResponse(BaseModel):
|
||||||
|
user_message: MessageOut
|
||||||
|
assistant_message: MessageOut
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
organization_id: str
|
||||||
|
project_id: str | None
|
||||||
|
thread_id: str | None
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
assigned_employee_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTaskRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=2, max_length=255)
|
||||||
|
description: str = Field(default="", max_length=10_000)
|
||||||
|
project_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
|
assigned_employee_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RunOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
task_id: str
|
||||||
|
employee_id: str | None
|
||||||
|
status: str
|
||||||
|
error: str | None
|
||||||
|
started_at: datetime | None
|
||||||
|
finished_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RunStepOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
index: int
|
||||||
|
kind: str
|
||||||
|
title: str
|
||||||
|
payload_json: dict
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tool_key: str
|
||||||
|
status: str
|
||||||
|
requires_approval: bool
|
||||||
|
arguments_json: dict
|
||||||
|
result_json: dict
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
content_ref: str
|
||||||
|
size_bytes: int
|
||||||
|
run_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RunDetailOut(RunOut):
|
||||||
|
steps: list[RunStepOut]
|
||||||
|
tool_calls: list[ToolCallOut]
|
||||||
|
artifacts: list[ArtifactOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDetailOut(TaskOut):
|
||||||
|
runs: list[RunOut]
|
||||||
|
|
||||||
|
|
||||||
|
class StartRunRequest(BaseModel):
|
||||||
|
employee_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _employee_out(employee) -> EmployeeOut:
|
||||||
|
return EmployeeOut(
|
||||||
|
id=employee.id, organization_id=employee.organization_id,
|
||||||
|
project_id=employee.project_id, template_id=employee.template_id,
|
||||||
|
name=employee.name, role_key=str(employee.role_key),
|
||||||
|
status=str(employee.status), routing_policy_id=employee.routing_policy_id,
|
||||||
|
created_at=employee.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_out(thread) -> ThreadOut:
|
||||||
|
return ThreadOut(
|
||||||
|
id=thread.id, organization_id=thread.organization_id,
|
||||||
|
project_id=thread.project_id, title=thread.title, created_at=thread.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_out(task) -> TaskOut:
|
||||||
|
return TaskOut(
|
||||||
|
id=task.id, organization_id=task.organization_id, project_id=task.project_id,
|
||||||
|
thread_id=task.thread_id, title=task.title, description=task.description,
|
||||||
|
status=str(task.status), assigned_employee_id=task.assigned_employee_id,
|
||||||
|
created_at=task.created_at, updated_at=task.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_out(run) -> RunOut:
|
||||||
|
return RunOut(
|
||||||
|
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
|
||||||
|
status=str(run.status), error=run.error, started_at=run.started_at,
|
||||||
|
finished_at=run.finished_at, created_at=run.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent-templates", response_model=list[AgentTemplateOut])
|
||||||
|
async def list_agent_templates(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> list[AgentTemplateOut]:
|
||||||
|
templates = await service.list_templates()
|
||||||
|
return [
|
||||||
|
AgentTemplateOut(
|
||||||
|
id=t.id, name=t.name, role_key=str(t.role_key), description=t.description,
|
||||||
|
default_tools_json=list(t.default_tools_json),
|
||||||
|
)
|
||||||
|
for t in templates
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/routing-choices", response_model=list[RoutingChoiceOut])
|
||||||
|
async def list_routing_choices(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> list[RoutingChoiceOut]:
|
||||||
|
"""Let a customer pick *how* an employee should work, without naming a model."""
|
||||||
|
policies = await service.list_routing_choices()
|
||||||
|
return [
|
||||||
|
RoutingChoiceOut(id=p.id, name=p.name, description=p.description) for p in policies
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Employees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/employees", response_model=EmployeeOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_employee(
|
||||||
|
payload: CreateEmployeeRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> EmployeeOut:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
|
||||||
|
employee = await service.create_employee(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
created_by_user_id=context.user.id,
|
||||||
|
name=payload.name,
|
||||||
|
role_key=payload.role_key,
|
||||||
|
template_id=payload.template_id,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
routing_policy_id=payload.routing_policy_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.EMPLOYEE_CREATE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
|
||||||
|
metadata={"name": employee.name, "role_key": str(employee.role_key)},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return _employee_out(employee)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees", response_model=list[EmployeeOut])
|
||||||
|
async def list_employees(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> list[EmployeeOut]:
|
||||||
|
return [
|
||||||
|
_employee_out(e)
|
||||||
|
for e in await service.list_employees(context.org_id, limit=limit, offset=offset)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees/{employee_id}", response_model=EmployeeDetailOut)
|
||||||
|
async def get_employee(
|
||||||
|
employee_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> EmployeeDetailOut:
|
||||||
|
employee = await service.get_employee_or_404(
|
||||||
|
organization_id=context.org_id, employee_id=employee_id
|
||||||
|
)
|
||||||
|
base = _employee_out(employee)
|
||||||
|
return EmployeeDetailOut(
|
||||||
|
**base.model_dump(),
|
||||||
|
prompt_versions=[
|
||||||
|
PromptVersionOut(
|
||||||
|
id=v.id, version=v.version, content=v.content, created_at=v.created_at
|
||||||
|
)
|
||||||
|
for v in await service.list_prompt_versions(employee.id)
|
||||||
|
],
|
||||||
|
tool_permissions=[
|
||||||
|
ToolPermissionOut(tool_key=p.tool_key, allowed=p.allowed)
|
||||||
|
for p in await service.list_tool_permissions(employee.id)
|
||||||
|
],
|
||||||
|
open_task_count=await service.tasks.count_open_for_employee(employee.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
|
||||||
|
async def update_employee(
|
||||||
|
employee_id: str,
|
||||||
|
payload: UpdateEmployeeRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> EmployeeOut:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
|
||||||
|
employee = await service.update_employee(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
employee_id=employee_id,
|
||||||
|
name=payload.name,
|
||||||
|
status=payload.status,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
routing_policy_id=payload.routing_policy_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.EMPLOYEE_UPDATE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
|
||||||
|
metadata={"status": str(employee.status)},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return _employee_out(employee)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/employees/{employee_id}/prompt-versions",
|
||||||
|
response_model=PromptVersionOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_prompt_version(
|
||||||
|
employee_id: str,
|
||||||
|
payload: CreatePromptVersionRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> PromptVersionOut:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
# Confirms tenant ownership before writing anything.
|
||||||
|
await service.get_employee_or_404(
|
||||||
|
organization_id=context.org_id, employee_id=employee_id
|
||||||
|
)
|
||||||
|
version = await service.add_prompt_version(
|
||||||
|
employee_id=employee_id, content=payload.content,
|
||||||
|
created_by_user_id=context.user.id,
|
||||||
|
)
|
||||||
|
return PromptVersionOut(
|
||||||
|
id=version.id, version=version.version, content=version.content,
|
||||||
|
created_at=version.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Threads & messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/threads", response_model=ThreadOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_thread(
|
||||||
|
payload: CreateThreadRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> ThreadOut:
|
||||||
|
thread = await service.create_thread(
|
||||||
|
organization_id=context.org_id, created_by_user_id=context.user.id,
|
||||||
|
title=payload.title, project_id=payload.project_id,
|
||||||
|
)
|
||||||
|
return _thread_out(thread)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/threads", response_model=list[ThreadOut])
|
||||||
|
async def list_threads(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> list[ThreadOut]:
|
||||||
|
return [
|
||||||
|
_thread_out(t)
|
||||||
|
for t in await service.list_threads(context.org_id, limit=limit, offset=offset)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/threads/{thread_id}", response_model=ThreadDetailOut)
|
||||||
|
async def get_thread(
|
||||||
|
thread_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> ThreadDetailOut:
|
||||||
|
thread = await service.get_thread_or_404(
|
||||||
|
organization_id=context.org_id, thread_id=thread_id
|
||||||
|
)
|
||||||
|
messages = await service.list_messages(thread.id)
|
||||||
|
base = _thread_out(thread)
|
||||||
|
return ThreadDetailOut(
|
||||||
|
**base.model_dump(),
|
||||||
|
messages=[
|
||||||
|
MessageOut(
|
||||||
|
id=m.id, thread_id=m.thread_id, role=str(m.role), content=m.content,
|
||||||
|
employee_id=m.employee_id, created_at=m.created_at,
|
||||||
|
)
|
||||||
|
for m in messages
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/threads/{thread_id}/messages",
|
||||||
|
response_model=PostMessageResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def post_message(
|
||||||
|
thread_id: str,
|
||||||
|
payload: PostMessageRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> PostMessageResponse:
|
||||||
|
context.require(CAN_RUN_TASKS)
|
||||||
|
|
||||||
|
user_message, assistant_message = await service.post_user_message(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
content=payload.content,
|
||||||
|
user_id=context.user.id,
|
||||||
|
employee_id=payload.employee_id,
|
||||||
|
)
|
||||||
|
return PostMessageResponse(
|
||||||
|
user_message=MessageOut(
|
||||||
|
id=user_message.id, thread_id=user_message.thread_id,
|
||||||
|
role=str(user_message.role), content=user_message.content,
|
||||||
|
employee_id=user_message.employee_id, created_at=user_message.created_at,
|
||||||
|
),
|
||||||
|
assistant_message=MessageOut(
|
||||||
|
id=assistant_message.id, thread_id=assistant_message.thread_id,
|
||||||
|
role=str(assistant_message.role), content=assistant_message.content,
|
||||||
|
employee_id=assistant_message.employee_id,
|
||||||
|
created_at=assistant_message.created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tasks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_task(
|
||||||
|
payload: CreateTaskRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> TaskOut:
|
||||||
|
context.require(CAN_RUN_TASKS)
|
||||||
|
|
||||||
|
task = await service.create_task(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
created_by_user_id=context.user.id,
|
||||||
|
title=payload.title,
|
||||||
|
description=payload.description,
|
||||||
|
project_id=payload.project_id,
|
||||||
|
thread_id=payload.thread_id,
|
||||||
|
assigned_employee_id=payload.assigned_employee_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.TASK_CREATE, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="task", target_id=task.id,
|
||||||
|
metadata={"title": task.title}, ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return _task_out(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks", response_model=list[TaskOut])
|
||||||
|
async def list_tasks(
|
||||||
|
status_filter: TaskStatus | None = Query(default=None, alias="status"),
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> list[TaskOut]:
|
||||||
|
tasks = await service.list_tasks(
|
||||||
|
context.org_id, status=status_filter, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return [_task_out(t) for t in tasks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}", response_model=TaskDetailOut)
|
||||||
|
async def get_task(
|
||||||
|
task_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> TaskDetailOut:
|
||||||
|
task = await service.get_task_or_404(organization_id=context.org_id, task_id=task_id)
|
||||||
|
runs = await service.list_task_runs(task.id)
|
||||||
|
base = _task_out(task)
|
||||||
|
return TaskDetailOut(**base.model_dump(), runs=[_run_out(r) for r in runs])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tasks/{task_id}/runs",
|
||||||
|
response_model=RunDetailOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def start_run(
|
||||||
|
task_id: str,
|
||||||
|
payload: StartRunRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> RunDetailOut:
|
||||||
|
context.require(CAN_RUN_TASKS)
|
||||||
|
|
||||||
|
run = await service.start_run(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
task_id=task_id,
|
||||||
|
user_id=context.user.id,
|
||||||
|
employee_id=payload.employee_id,
|
||||||
|
)
|
||||||
|
return await _run_detail(service, context.org_id, run.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}", response_model=RunDetailOut)
|
||||||
|
async def get_run(
|
||||||
|
run_id: str,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> RunDetailOut:
|
||||||
|
return await _run_detail(service, context.org_id, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/{run_id}/cancel", response_model=RunDetailOut)
|
||||||
|
async def cancel_run(
|
||||||
|
run_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> RunDetailOut:
|
||||||
|
context.require(CAN_RUN_TASKS)
|
||||||
|
|
||||||
|
run = await service.cancel_run(organization_id=context.org_id, run_id=run_id)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.RUN_CANCEL, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="run", target_id=run.id,
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return await _run_detail(service, context.org_id, run.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/approvals/pending", response_model=list[ToolCallOut])
|
||||||
|
async def list_pending_approvals(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> list[ToolCallOut]:
|
||||||
|
calls = await service.list_pending_approvals(context.org_id)
|
||||||
|
return [
|
||||||
|
ToolCallOut(
|
||||||
|
id=c.id, tool_key=c.tool_key, status=str(c.status),
|
||||||
|
requires_approval=c.requires_approval,
|
||||||
|
arguments_json=dict(c.arguments_json), result_json=dict(c.result_json),
|
||||||
|
created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in calls
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool catalog & approvals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools", response_model=list[ToolSpecOut])
|
||||||
|
async def list_tools(
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> list[ToolSpecOut]:
|
||||||
|
"""The catalog of tools an employee can be granted."""
|
||||||
|
return [
|
||||||
|
ToolSpecOut(
|
||||||
|
key=spec.key, label=spec.label, description=spec.description,
|
||||||
|
requires_approval=spec.requires_approval, destructive=spec.destructive,
|
||||||
|
category=spec.category,
|
||||||
|
)
|
||||||
|
for spec in service.tool_catalog()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/employees/{employee_id}/tools/{tool_key}", response_model=ToolPermissionOut
|
||||||
|
)
|
||||||
|
async def set_tool_permission(
|
||||||
|
employee_id: str,
|
||||||
|
tool_key: str,
|
||||||
|
payload: SetToolPermissionRequest,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
) -> ToolPermissionOut:
|
||||||
|
context.require(CAN_MANAGE_EMPLOYEES)
|
||||||
|
permission = await service.set_tool_permission(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
employee_id=employee_id,
|
||||||
|
tool_key=tool_key,
|
||||||
|
allowed=payload.allowed,
|
||||||
|
)
|
||||||
|
return ToolPermissionOut(tool_key=permission.tool_key, allowed=permission.allowed)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/approvals/{tool_call_id}/decide", response_model=ToolCallOut)
|
||||||
|
async def decide_approval(
|
||||||
|
tool_call_id: str,
|
||||||
|
payload: DecideApprovalRequest,
|
||||||
|
request: Request,
|
||||||
|
context: OrgContext = Depends(get_org_context),
|
||||||
|
service: WorkforceService = Depends(get_workforce_service),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ToolCallOut:
|
||||||
|
context.require(CAN_RUN_TASKS)
|
||||||
|
|
||||||
|
call = await service.decide_approval(
|
||||||
|
organization_id=context.org_id,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
approve=payload.approve,
|
||||||
|
decided_by_user_id=context.user.id,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.APPROVAL_DECISION, organization_id=context.org_id,
|
||||||
|
actor_user_id=context.user.id, target_type="tool_call", target_id=call.id,
|
||||||
|
metadata={"tool_key": call.tool_key, "approved": payload.approve},
|
||||||
|
ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
return ToolCallOut(
|
||||||
|
id=call.id, tool_key=call.tool_key, status=str(call.status),
|
||||||
|
requires_approval=call.requires_approval,
|
||||||
|
arguments_json=dict(call.arguments_json), result_json=dict(call.result_json),
|
||||||
|
created_at=call.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_detail(
|
||||||
|
service: WorkforceService, organization_id: str, run_id: str
|
||||||
|
) -> RunDetailOut:
|
||||||
|
run, steps, tool_calls, artifacts = await service.get_run_or_404(
|
||||||
|
organization_id=organization_id, run_id=run_id
|
||||||
|
)
|
||||||
|
base = _run_out(run)
|
||||||
|
return RunDetailOut(
|
||||||
|
**base.model_dump(),
|
||||||
|
steps=[
|
||||||
|
RunStepOut(
|
||||||
|
id=s.id, index=s.index, kind=s.kind, title=s.title,
|
||||||
|
payload_json=dict(s.payload_json), created_at=s.created_at,
|
||||||
|
)
|
||||||
|
for s in steps
|
||||||
|
],
|
||||||
|
tool_calls=[
|
||||||
|
ToolCallOut(
|
||||||
|
id=c.id, tool_key=c.tool_key, status=str(c.status),
|
||||||
|
requires_approval=c.requires_approval,
|
||||||
|
arguments_json=dict(c.arguments_json),
|
||||||
|
result_json=dict(c.result_json), created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in tool_calls
|
||||||
|
],
|
||||||
|
artifacts=[
|
||||||
|
ArtifactOut(
|
||||||
|
id=a.id, name=a.name, kind=a.kind, content_ref=a.content_ref,
|
||||||
|
size_bytes=a.size_bytes, run_id=a.run_id, created_at=a.created_at,
|
||||||
|
)
|
||||||
|
for a in artifacts
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""Idempotent development seed.
|
||||||
|
|
||||||
|
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
|
||||||
|
organization that both of them own, the system agent templates, routing
|
||||||
|
policies, rate cards and a mock provider with two models.
|
||||||
|
|
||||||
|
Safe to run repeatedly: every step checks for existing rows first.
|
||||||
|
|
||||||
|
The two accounts it prints at the end are meant to be usable immediately —
|
||||||
|
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
|
||||||
|
login endpoint rejects is worse than no seed at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import Base, SessionFactory, engine
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.domain.ai_control.models import (
|
||||||
|
AiModel,
|
||||||
|
Capability,
|
||||||
|
Provider,
|
||||||
|
ProviderType,
|
||||||
|
RateCard,
|
||||||
|
RateUnit,
|
||||||
|
RoutingMode,
|
||||||
|
RoutingPolicy,
|
||||||
|
)
|
||||||
|
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User
|
||||||
|
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
|
||||||
|
from app.domain.workforce.models import AgentTemplate, RoleKey
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
AgentTemplateRow,
|
||||||
|
AiModelRow,
|
||||||
|
AiProviderRow,
|
||||||
|
CreditWalletRow,
|
||||||
|
RateCardRow,
|
||||||
|
RoutingPolicyRow,
|
||||||
|
SandboxTemplateRow,
|
||||||
|
UserRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
|
||||||
|
logger = logging.getLogger("agy.seed")
|
||||||
|
|
||||||
|
#: ``example.com`` is a documentation domain and passes email validation.
|
||||||
|
#: Do NOT use ``.local``/``.test``/``.invalid``: those are special-use TLDs and
|
||||||
|
#: ``email-validator`` rejects them, so the seed would create an account that the
|
||||||
|
#: API then refuses to log in — the user exists but is unreachable.
|
||||||
|
PLATFORM_OWNER_EMAIL = "owner@example.com"
|
||||||
|
PLATFORM_OWNER_PASSWORD = "owner12345"
|
||||||
|
DEMO_EMAIL = "demo@example.com"
|
||||||
|
DEMO_PASSWORD = "demo12345"
|
||||||
|
#: Stable identifiers for the sample organization. Both must stay constant for
|
||||||
|
#: the seed to be idempotent — see ``_ensure_org``.
|
||||||
|
DEMO_ORG_NAME = "سازمان نمونه"
|
||||||
|
DEMO_ORG_SLUG = "sample-org"
|
||||||
|
|
||||||
|
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
|
||||||
|
(
|
||||||
|
"مدیر سازمان", RoleKey.MANAGER,
|
||||||
|
"کارها را دستهبندی میکند و بین کارمندها تقسیم میکند.",
|
||||||
|
["knowledge.search", "artifact.create"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"تحلیلگر و معمار", RoleKey.PLANNER,
|
||||||
|
"نیازمندیها را تحلیل و پلن اجرایی میسازد.",
|
||||||
|
["knowledge.search"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"برنامهنویس", RoleKey.DEVELOPER,
|
||||||
|
"کد مینویسد و در sandbox اجرا و تست میکند.",
|
||||||
|
["git.ensure_repo", "git.create_branch", "sandbox.run_command",
|
||||||
|
"sandbox.read_file", "sandbox.write_file"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"بازبین کد", RoleKey.REVIEWER,
|
||||||
|
"PRها را بازبینی میکند و بازخورد میدهد.",
|
||||||
|
["git.open_pr"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"تستر", RoleKey.TESTER,
|
||||||
|
"تست مینویسد و اجرا میکند.",
|
||||||
|
["sandbox.run_command", "sandbox.write_file"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"محقق", RoleKey.RESEARCHER,
|
||||||
|
"منابع را جمع و خلاصه میکند.",
|
||||||
|
["knowledge.search", "artifact.create"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"تولیدکننده محتوا", RoleKey.CONTENT_CREATOR,
|
||||||
|
"متن و محتوای سازمانی تولید میکند.",
|
||||||
|
["artifact.create"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"پشتیبان مشتری", RoleKey.SUPPORT,
|
||||||
|
"به پرسشهای مشتری پاسخ میدهد.",
|
||||||
|
["knowledge.search"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROUTING_POLICIES: list[tuple[str, RoutingMode, bool, str]] = [
|
||||||
|
("سریع و ارزان", RoutingMode.CHEAP, True, "برای کارهای روزمره و پرتکرار."),
|
||||||
|
("متعادل", RoutingMode.BALANCED, False, "پیشفرض متعادل بین هزینه و کیفیت."),
|
||||||
|
("استدلال عمیق", RoutingMode.REASONING, False, "برای تحلیل و کارهای پیچیده."),
|
||||||
|
("کدنویسی", RoutingMode.CODING, False, "بهینه برای تولید و اصلاح کد."),
|
||||||
|
("چندرسانهای", RoutingMode.VISION, False, "برای ورودی تصویری."),
|
||||||
|
]
|
||||||
|
|
||||||
|
RATE_CARDS: list[tuple[RateUnit, float, float]] = [
|
||||||
|
(RateUnit.MODEL_INPUT_TOKENS, 0.0000004, 0.0000002),
|
||||||
|
(RateUnit.MODEL_OUTPUT_TOKENS, 0.0000016, 0.0000008),
|
||||||
|
(RateUnit.SANDBOX_CPU_SECOND, 0.00002, 0.00001),
|
||||||
|
(RateUnit.SANDBOX_MEMORY_GB_SECOND, 0.000004, 0.000002),
|
||||||
|
(RateUnit.STORAGE_GB_DAY, 0.0004, 0.0002),
|
||||||
|
(RateUnit.TOOL_CALL, 0.0005, 0.0001),
|
||||||
|
(RateUnit.PREMIUM_EMPLOYEE_RUN, 0.02, 0.008),
|
||||||
|
]
|
||||||
|
|
||||||
|
#: (name, image, description, cpu, memory_mb, disk_mb)
|
||||||
|
SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
|
||||||
|
(
|
||||||
|
"python-3.13", "python:3.13-slim",
|
||||||
|
"محیط پایتون برای اجرای تست و اسکریپت.", 1.0, 1024, 2048,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"node-22", "node:22-slim",
|
||||||
|
"محیط Node برای build و اجرای پروژههای فرانتاند.", 1.0, 2048, 4096,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ubuntu-base", "ubuntu:24.04",
|
||||||
|
"محیط عمومی لینوکس برای ابزارهای خط فرمان.", 1.0, 1024, 2048,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_user(
|
||||||
|
session, *, email: str, password: str, name: str, platform_owner: bool
|
||||||
|
) -> User:
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(UserRow).where(UserRow.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
return User(
|
||||||
|
id=existing.id, email=existing.email, password_hash=existing.password_hash,
|
||||||
|
name=existing.name, is_platform_owner=existing.is_platform_owner,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.identity import UserRepository
|
||||||
|
|
||||||
|
user = await UserRepository(session).create(
|
||||||
|
User(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
name=name,
|
||||||
|
is_platform_owner=platform_owner,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("created user %s", email)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_org(session, *, members: list[User]) -> Organization:
|
||||||
|
"""Get or create the sample organization, with every given user in it.
|
||||||
|
|
||||||
|
Idempotent in all three parts: the organization, its memberships and its
|
||||||
|
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with
|
||||||
|
exactly one organization and no duplicate rows.
|
||||||
|
"""
|
||||||
|
from app.infrastructure.repositories.identity import (
|
||||||
|
MembershipRepository,
|
||||||
|
OrganizationRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
orgs = OrganizationRepository(session)
|
||||||
|
memberships = MembershipRepository(session)
|
||||||
|
|
||||||
|
# A *fixed* slug, not `slugify(name)`. The name is Persian, and `slugify`
|
||||||
|
# falls back to a random `x<hex>` when it cannot transliterate — so deriving
|
||||||
|
# the slug here would produce a different value on every run, `get_by_slug`
|
||||||
|
# would always miss, and re-seeding would silently create a duplicate
|
||||||
|
# organization (and a duplicate wallet) each time.
|
||||||
|
org = await orgs.get_by_slug(DEMO_ORG_SLUG)
|
||||||
|
if org is None:
|
||||||
|
org = await orgs.create(
|
||||||
|
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id)
|
||||||
|
)
|
||||||
|
logger.info("created organization %s", org.slug)
|
||||||
|
|
||||||
|
# Every listed user becomes an owner of the sample org. Without this the
|
||||||
|
# demo account can log in but belongs to no organization, so every screen
|
||||||
|
# renders empty and the account looks broken.
|
||||||
|
for user in members:
|
||||||
|
if await memberships.get(org.id, user.id) is None:
|
||||||
|
await memberships.add(
|
||||||
|
Membership(
|
||||||
|
organization_id=org.id, user_id=user.id,
|
||||||
|
role=Role.OWNER, status=MembershipStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("added %s to %s as owner", user.email, org.slug)
|
||||||
|
|
||||||
|
wallet = (
|
||||||
|
await session.execute(
|
||||||
|
select(CreditWalletRow).where(CreditWalletRow.organization_id == org.id)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if wallet is None:
|
||||||
|
session.add(CreditWalletRow(organization_id=org.id, balance=25.0, currency="USD"))
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def seed() -> None:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
owner = await _ensure_user(
|
||||||
|
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
|
||||||
|
name="مالک سامانه", platform_owner=True,
|
||||||
|
)
|
||||||
|
demo = await _ensure_user(
|
||||||
|
session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
|
||||||
|
name="کاربر نمونه", platform_owner=False,
|
||||||
|
)
|
||||||
|
# Both accounts join the sample organization, so a developer who logs in
|
||||||
|
# as either one lands on a populated dashboard rather than an empty one.
|
||||||
|
await _ensure_org(session, members=[owner, demo])
|
||||||
|
|
||||||
|
if not (await session.execute(select(AiProviderRow))).scalars().first():
|
||||||
|
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
|
||||||
|
session.add(
|
||||||
|
AiProviderRow(
|
||||||
|
id=provider.id, name=provider.name, type=provider.type.value,
|
||||||
|
base_url=provider.base_url, is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
for internal, display, caps in (
|
||||||
|
("mock-fast", "Mock Fast", [Capability.TEXT]),
|
||||||
|
(
|
||||||
|
"mock-reasoning",
|
||||||
|
"Mock Reasoning",
|
||||||
|
[Capability.TEXT, Capability.REASONING, Capability.CODE],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
model = AiModel(
|
||||||
|
provider_id=provider.id,
|
||||||
|
internal_name=internal,
|
||||||
|
display_name_for_admin=display,
|
||||||
|
capabilities=[str(c) for c in caps],
|
||||||
|
input_price_per_1m=0.3,
|
||||||
|
output_price_per_1m=1.2,
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
AiModelRow(
|
||||||
|
id=model.id, provider_id=model.provider_id,
|
||||||
|
internal_name=model.internal_name,
|
||||||
|
display_name_for_admin=model.display_name_for_admin,
|
||||||
|
capabilities=model.capabilities,
|
||||||
|
input_price_per_1m=model.input_price_per_1m,
|
||||||
|
output_price_per_1m=model.output_price_per_1m,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("created mock provider + 2 models")
|
||||||
|
|
||||||
|
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
|
||||||
|
for name, role_key, description, tools in AGENT_TEMPLATES:
|
||||||
|
template = AgentTemplate(
|
||||||
|
name=name, role_key=role_key, description=description,
|
||||||
|
default_prompt=f"تو {name} هستی. وظیفهات: {description}",
|
||||||
|
default_tools_json=tools,
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
AgentTemplateRow(
|
||||||
|
id=template.id, name=template.name, role_key=template.role_key.value,
|
||||||
|
description=template.description, default_prompt=template.default_prompt,
|
||||||
|
default_tools_json=template.default_tools_json, is_system=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("created %d agent templates", len(AGENT_TEMPLATES))
|
||||||
|
|
||||||
|
if not (await session.execute(select(RoutingPolicyRow))).scalars().first():
|
||||||
|
for name, mode, is_default, description in ROUTING_POLICIES:
|
||||||
|
policy = RoutingPolicy(
|
||||||
|
name=name, mode=mode, description=description, is_default=is_default
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
RoutingPolicyRow(
|
||||||
|
id=policy.id, name=policy.name, description=policy.description,
|
||||||
|
mode=policy.mode.value, is_default=policy.is_default, is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("created %d routing policies", len(ROUTING_POLICIES))
|
||||||
|
|
||||||
|
if not (await session.execute(select(RateCardRow))).scalars().first():
|
||||||
|
for unit, customer_price, internal_cost in RATE_CARDS:
|
||||||
|
card = RateCard(
|
||||||
|
name=unit.value.replace("_", " "),
|
||||||
|
unit=unit,
|
||||||
|
customer_price=customer_price,
|
||||||
|
internal_cost_estimate=internal_cost,
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
RateCardRow(
|
||||||
|
id=card.id, name=card.name, unit=card.unit.value,
|
||||||
|
customer_price=card.customer_price,
|
||||||
|
internal_cost_estimate=card.internal_cost_estimate, is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("created %d rate cards", len(RATE_CARDS))
|
||||||
|
|
||||||
|
if not (await session.execute(select(SandboxTemplateRow))).scalars().first():
|
||||||
|
for name, image, description, cpu, memory_mb, disk_mb in SANDBOX_TEMPLATES:
|
||||||
|
# Named distinctly from the agent-template loop above so the two
|
||||||
|
# entities cannot be confused.
|
||||||
|
sandbox_template = SandboxTemplate(
|
||||||
|
name=name, provider=SandboxProviderKind.MOCK, image=image,
|
||||||
|
description=description, default_cpu=cpu,
|
||||||
|
default_memory_mb=memory_mb, default_disk_mb=disk_mb,
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
SandboxTemplateRow(
|
||||||
|
id=sandbox_template.id, name=sandbox_template.name,
|
||||||
|
provider=str(sandbox_template.provider),
|
||||||
|
image=sandbox_template.image,
|
||||||
|
description=sandbox_template.description,
|
||||||
|
default_cpu=sandbox_template.default_cpu,
|
||||||
|
default_memory_mb=sandbox_template.default_memory_mb,
|
||||||
|
default_disk_mb=sandbox_template.default_disk_mb,
|
||||||
|
is_system=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("created %d sandbox templates", len(SANDBOX_TEMPLATES))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info("seed complete")
|
||||||
|
logger.info("platform owner: %s / %s", PLATFORM_OWNER_EMAIL, PLATFORM_OWNER_PASSWORD)
|
||||||
|
logger.info("demo user: %s / %s", DEMO_EMAIL, DEMO_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(seed())
|
||||||
|
except Exception as exc:
|
||||||
|
# A failed seed is almost always a first-run problem — the very moment
|
||||||
|
# there is nobody watching a terminal. Record it before exiting non-zero.
|
||||||
|
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||||
|
from app.infrastructure.error_log import record_error
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
record_error(
|
||||||
|
kind="seed_failed",
|
||||||
|
message=str(exc) or type(exc).__name__,
|
||||||
|
exc=exc,
|
||||||
|
source=ErrorSource.STARTUP,
|
||||||
|
severity=ErrorSeverity.CRITICAL,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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())
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user