مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
341 lines
20 KiB
Markdown
341 lines
20 KiB
Markdown
# نمای کلی معماری — نسخه وب مستقل AGY
|
||
|
||
> این سند تصویر فنی پروژهٔ `web/` است: چه چیزی کجاست، چرا اینطور چیده شده، و
|
||
> مرزهای اعتماد کداماند. برای چکلیست امنیتی به
|
||
> [`../security-checklist.md`](../security-checklist.md) مراجعه کنید.
|
||
|
||
---
|
||
|
||
## ۱. هدف و قابلیتهای محصول
|
||
|
||
یک **SaaS چندمستأجری مستقل** بهنام «AI Workforce Platform». مشتری وارد میشود،
|
||
سازمان میسازد، عضو دعوت میکند، پروژه تعریف میکند و به کارمندان هوش مصنوعی کار
|
||
میسپارد. مشتری هرگز نمیبیند که پشت صحنه چه مدلی، چه providerی یا چه کلیدی است.
|
||
|
||
**آنچه مشتری میبیند:** سازمان، اعضا، پروژه، مخزن Git، دامنهٔ preview، محیط
|
||
اجرای ایزوله، کارمند AI، گفتگو با مدیر، task و run، مصرف، صورتحساب، audit log.
|
||
|
||
**آنچه فقط مالک سامانه میبیند:** provider، مدل، سیاست مسیریابی، rate card،
|
||
کلیدهای رمزنگاریشده، همهٔ سازمانها.
|
||
|
||
**مستقل از تلگرام:** نسخهٔ وب هیچ import یا وابستگی runtime به `bot.py`,
|
||
`web_panel.py`, `caddy_manager.py`, `git_manager.py` یا `gitea_api.py` ندارد. منطق
|
||
Git و domain routing فقط بهعنوان مرجع خوانده و در `web/` بهصورت adapter مستقل
|
||
بازنویسی شده است.
|
||
|
||
---
|
||
|
||
## ۲. استک
|
||
|
||
| لایه | فناوری | چرا |
|
||
| --- | --- | --- |
|
||
| UI | Next.js 15.1.6 (App Router) + React 19 + TypeScript | RSC، مسیریابی فایلمحور، یک پکیج مشترک برای کل محصول |
|
||
| استایل | Tailwind CSS v3 | RTL و dark-first بدون فایل CSS پراکنده |
|
||
| State/Data | کلاینت `fetch` نازک + `localStorage` برای توکن | بدون وابستگی اضافه؛ TanStack Query لازم نشد |
|
||
| API | FastAPI + Pydantic v2 | قرارداد تایپدار، OpenAPI خودکار |
|
||
| ORM | SQLAlchemy 2.x (async) | همراستا با مدل async FastAPI |
|
||
| Migration | Alembic | تنها مسیر تغییر schema در production |
|
||
| DB | PostgreSQL (توسعه: SQLite حافظهای برای تست) | تستها بدون سرویس بیرونی اجرا میشوند |
|
||
| Cache/Queue | Redis | آماده برای workerهای background |
|
||
| امنیت | argon2 (رمز)، PyJWT (نشست)، cryptography/Fernet (رازها) | انتخابهای استاندارد و مقاوم |
|
||
| تست | pytest + pytest-asyncio (backend)، Vitest + Testing Library (frontend) | — |
|
||
|
||
---
|
||
|
||
## ۳. چیدمان مخزن
|
||
|
||
```
|
||
web/
|
||
├── apps/
|
||
│ ├── api/ ← بکاند FastAPI (Clean/Hexagonal)
|
||
│ └── web/ ← فرانتاند Next.js
|
||
├── packages/
|
||
│ ├── contracts/ ← تایپهای مشترک request/response
|
||
│ └── ui/ ← کامپوننتهای پایه (RTL، dark-first)
|
||
├── services/ ← workerهای مستقل (ai-runtime, billing-worker, sandbox-gateway)
|
||
├── infra/docker/ ← docker-compose برای postgres/redis
|
||
├── docs/ ← همین اسناد
|
||
├── PLAN.md ← سند مرجع محصول و معماری
|
||
└── TODO.md ← فهرست اجرای مرحلهبهمرحله
|
||
```
|
||
|
||
---
|
||
|
||
## ۴. معماری بکاند — Clean / Hexagonal
|
||
|
||
وابستگیها **همیشه به سمت داخل** است. لایهٔ domain به هیچ فریمورک، ORM، Redis یا
|
||
HTTP وابسته نیست (این با تست `test_domain_layer_has_no_framework_dependencies`
|
||
تضمین شده است).
|
||
|
||
```
|
||
┌───────────────────────────────────────────────────────────────────┐
|
||
│ interfaces/http routers + deps + error handlers │
|
||
│ فقط route، schema ورودی/خروجی و تبدیل خطا │
|
||
├───────────────────────────────────────────────────────────────────┤
|
||
│ application services (use case) + ports (driven iface) │
|
||
│ sandbox_service, workforce_service, project_service, ... │
|
||
├───────────────────────────────────────────────────────────────────┤
|
||
│ domain قواعد خالص، بدون I/O │
|
||
│ identity, projects, workforce, sandbox, billing, audit, │
|
||
│ knowledge, ai_control, platform/rules │
|
||
├───────────────────────────────────────────────────────────────────┤
|
||
│ infrastructure adapters + repositories + db/models │
|
||
│ mock, gitea, caddy, openai_compatible, sandbox_remote, stripe │
|
||
└───────────────────────────────────────────────────────────────────┘
|
||
↑ همهٔ پیکانها به سمت domain
|
||
```
|
||
|
||
### ۴.۱ لایهٔ domain (بدون I/O)
|
||
|
||
هر bounded context یک پکیج است با `models.py` و قواعد مربوط به خود:
|
||
|
||
| Context | مسئولیت |
|
||
| --- | --- |
|
||
| `identity` | کاربر، سازمان، عضویت، نقش، دعوت |
|
||
| `projects` | پروژه، وضعیت مخزن، دامنهٔ preview |
|
||
| `workforce` | قالب کارمند، کارمند، thread، message، task، run، artifact، مجوز ابزار (`tools.py`) |
|
||
| `sandbox` | workspace، session، snapshot، `NetworkPolicy`، `SandboxResourceLimit` |
|
||
| `billing` | پلن، rate card، usage ledger |
|
||
| `audit` | audit event، trace، span، error، quality metric |
|
||
| `knowledge` | منبع دانش، chunk |
|
||
| `ai_control` | provider، مدل، سیاست مسیریابی |
|
||
| `platform/rules.py` | **قواعد امنیتی سکو:** اعتبارسنجی برچسب دامنه، ضدتزریق host، ضدSSRF، پورتهای رزرو/محافظتشده |
|
||
|
||
`platform/rules.py` عمداً در domain است، نه در infrastructure: این قواعد باید
|
||
بدون DB و بدون شبکه قابل تست باشند، چون بیشترین بار امنیتی را دارند.
|
||
|
||
### ۴.۲ لایهٔ application
|
||
|
||
**سرویسها (use case):**
|
||
|
||
- `project_service` — ساخت پروژه، اطمینان از مخزن، دامنهٔ preview
|
||
- `workforce_service` — کارمند، prompt، مجوز ابزار، thread، task، اجرای run
|
||
- `sandbox_service` — workspace، session، اجرای فرمان، clamp منابع، ثبت مصرف
|
||
- `billing_service` — خلاصهٔ مصرف، صورتحساب
|
||
- `knowledge_service` — منابع دانش و شمارش chunk
|
||
- `audit_service` — audit log، trace
|
||
|
||
**پورتها (interfaceهای driven):** در `app/application/ports/`
|
||
|
||
`ModelProviderPort`, `PaymentProviderPort`, `SandboxProviderPort`,
|
||
`GitProviderPort`, `DomainRouterPort`, `StoragePort`, `VectorStorePort`,
|
||
`NotificationPort`, `UsageLedgerPort`, `AuditPort`.
|
||
|
||
هر پورت حداقل یک **adapter mock** دارد، بنابراین کل استک بدون هیچ credential
|
||
بیرونی بالا میآید.
|
||
|
||
### ۴.۳ لایهٔ infrastructure
|
||
|
||
**انتخاب adapter — یک نقطهٔ تصمیم:** `infrastructure/adapters/registry.py`
|
||
تنها جایی است که تصمیم میگیرد کدام adapter پشت هر پورت بنشیند، فقط بر اساس
|
||
سوئیچهای env:
|
||
|
||
| env | مقادیر مجاز | adapter واقعی |
|
||
| --- | --- | --- |
|
||
| `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` | `openai_compatible.py` |
|
||
| `GIT_PROVIDER` | `mock` \| `gitea` | `gitea.py` |
|
||
| `DOMAIN_ROUTER` | `mock` \| `caddy` | `caddy.py` |
|
||
| `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | `sandbox_remote.py` |
|
||
| `PAYMENT_PROVIDER` | `mock` \| `stripe` | `stripe_payments.py` |
|
||
|
||
مقدار ناشناخته به `ProviderUnavailableError` منجر میشود — **fail-closed**، نه
|
||
fallback بیصدا به mock.
|
||
|
||
**Repositoryها:** `infrastructure/repositories/` — `identity`, `projects`,
|
||
`workforce`, `sandbox`, `billing`, `knowledge`, `ai_control`, `audit`.
|
||
هر متد لیست، `limit`/`offset` میگیرد و سقف `min(limit, 500)` را اعمال میکند.
|
||
|
||
**مدلهای DB:** `infrastructure/db/models.py` — ۴۸ جدول با indexهای ترکیبی روی
|
||
`(organization_id, created_at)` برای مسیرهای داغ داشبورد.
|
||
|
||
### ۴.۴ لایهٔ interfaces
|
||
|
||
`interfaces/http/` شامل `routers/` (۹ روتر: `health`, `auth`, `organizations`,
|
||
`projects`, `workforce`, `billing`, `knowledge`, `sandbox`, `admin`)،
|
||
`deps.py` (auth، org context، capability) و `errors.py` (تبدیل استثناها به
|
||
پاسخهای یکدست). `main.py` تنها composition root است: روترها را به هم میبندد و
|
||
CORS را تنظیم میکند.
|
||
|
||
---
|
||
|
||
## ۵. معماری فرانتاند
|
||
|
||
Next.js App Router با دو ناحیهٔ مسیر:
|
||
|
||
**ناحیهٔ عمومی (بدون احراز هویت):** `/login`, `/signup`, `/onboarding`
|
||
|
||
**ناحیهٔ اپلیکیشن (`(app)` — نیازمند نشست):**
|
||
`/dashboard`, `/workforce`, `/workforce/[id]`, `/manager`, `/tasks`,
|
||
`/tasks/[id]`, `/projects`, `/projects/[id]`, `/knowledge`, `/billing`,
|
||
`/settings`, `/traces`
|
||
|
||
**ناحیهٔ مالک سامانه:** `/admin`, `/admin/providers`, `/admin/models`,
|
||
`/admin/routing`, `/admin/rate-cards`, `/admin/organizations`, `/admin/errors`
|
||
|
||
**دو نقطهٔ اتصال مرکزی:**
|
||
|
||
- `src/lib/api.ts` — تنها جایی که base URL، bearer token و پوشش خطا را میشناسد.
|
||
base URL از `NEXT_PUBLIC_API_URL` میآید (یک URL عمومی، نه راز).
|
||
- `src/lib/session.tsx` — Context نشست: کاربر، سازمانها، سازمان فعال، ورود/خروج.
|
||
|
||
**پکیج مشترک UI:** `packages/ui/src/index.tsx` کامپوننتهای پایه را دارد —
|
||
`Button`, `Card`, `Input`, `Badge`, `Modal`, `Drawer`, `Table` (با حالت
|
||
empty/loading/error داخلی), `Tabs`, `ToastStack`, `ArtifactViewer`. RTL و
|
||
dark-first بهصورت پیشفرض.
|
||
|
||
---
|
||
|
||
## ۶. مسیر یک درخواست (مثال: اجرای یک task)
|
||
|
||
```
|
||
مشتری: دکمهٔ «اجرا» در /tasks/[id]
|
||
│
|
||
├─ POST /api/organizations/{org}/tasks/{task}/runs (Bearer token)
|
||
│
|
||
├─ deps.get_current_user → توکن را decode میکند، کاربر را میخواند (۴۰۱ اگر نامعتبر)
|
||
├─ deps.get_org_context → عضویت فعال را میخواند (۴۰۴ اگر نباشد — نه ۴۰۳)
|
||
├─ deps.require_capability(...) → نقش کافی است؟ (۴۰۳ اگر نه)
|
||
│
|
||
├─ workforce_service.start_run(...)
|
||
│ ├─ انتخاب سیاست مسیریابی بر اساس mode (بدون افشای مدل به مشتری)
|
||
│ ├─ اجرای گامها → در صورت نیاز، sandbox_service.run_command(...)
|
||
│ │ ├─ SandboxProviderPort (mock یا remote)
|
||
│ │ ├─ clamp timeout/output از policy (نه از caller)
|
||
│ │ └─ ثبت usage در UsageLedger
|
||
│ └─ ثبت گامهای timeline (kind="output" — بدون افشای «model»)
|
||
│
|
||
└─ پاسخ: run با timeline، وضعیت، و مصرف ثبتشده
|
||
```
|
||
|
||
---
|
||
|
||
## ۷. مرزهای اعتماد
|
||
|
||
1. **مرز مستأجر:** هر endpoint چندسازمانی `organization_id` را enforce میکند.
|
||
نبود عضویت → **۴۰۴**. هیچ مسیری برای دیدن دادهٔ سازمان دیگر وجود ندارد.
|
||
2. **مرز مشتری/سکو:** provider، مدل و کلید هرگز از API به مشتری نمیرسند. مشتری
|
||
فقط یک *نام* سیاست مسیریابی میبیند.
|
||
3. **مرز اجرای کد:** هیچ مسیری برای اجرا روی host وجود ندارد. همهٔ کد کاربر فقط
|
||
از `SandboxProviderPort` عبور میکند، با egress پیشفرض بسته (`DENY_ALL`).
|
||
4. **مرز راز:** رازها در حالت سکون با Fernet رمزنگاری میشوند؛ در پاسخها فقط
|
||
نسخهٔ masked برمیگردد. frontend هیچ رازی ندارد.
|
||
|
||
---
|
||
|
||
## ۸. جریان داده و ثبت مصرف
|
||
|
||
**usage ledger منبع حقیقت صورتحساب است** — providerهای پرداخت فقط پول جابهجا
|
||
میکنند و در محاسبه دخالتی ندارند. هر مصرف (model call، زمان sandbox،
|
||
storage/artifact، tool call) یک رکورد در ledger میگذارد با `organization_id`,
|
||
`project_id`, `user_id`, `employee_id`, `run_id`, `unit`, `quantity`, `unit_price`.
|
||
|
||
هر عملیات حساس یک `AuditEvent` هم مینویسد (ساخت سازمان، دعوت عضو، ساخت repo،
|
||
ساخت دامنه، اجرای فرمان، حذف).
|
||
|
||
---
|
||
|
||
## ۹. تست
|
||
|
||
**بکاند (`apps/api/tests/`)** — ۲۱۶ تست:
|
||
|
||
| فایل | چه چیزی را میسنجد |
|
||
| --- | --- |
|
||
| `test_auth.py` | ثبتنام، ورود، نشست، نشتنداشتن hash |
|
||
| `test_tenant_isolation.py` | جداسازی مستأجر، ۴۰۴ بهجای ۴۰۳، دعوت |
|
||
| `test_admin_control_plane.py` | دسترسی مالک سامانه، mask شدن کلید |
|
||
| `test_platform_rules.py` | برچسب دامنه، ضدتزریق host، ضدSSRF، پورتها |
|
||
| `test_project_repo.py` | مخزن خصوصی، نشتنداشتن توکن |
|
||
| `test_workforce.py` | کارمند، مجوز ابزار (deny-by-default)، run، نشتنداشتن مدل |
|
||
| `test_config_guard.py` | اعتبارسنجی رازها در production |
|
||
| `test_pagination.py` | سقف صفحهبندی، رد offset نامعتبر |
|
||
| `test_domain_isolation.py` | نبود وابستگی فریمورک در domain |
|
||
| `test_e2e_smoke.py` | کل مسیر محصول + نامرئیبودن برای مستأجر دیگر |
|
||
| `test_workers.py` | اجرای task بدون کلیک، idempotency صورتحساب، reaping سندباکس |
|
||
| `test_seed.py` | قابلورود بودن حسابهای نمونه، idempotency seed |
|
||
| `test_redact.py` | پاکسازی secret در ۴۲ حالت (ساختاری، الگویی، مقداری) |
|
||
| `test_error_log.py` | مسیر کامل خطا: middleware → handler → recorder → دیتابیس |
|
||
| `test_error_console.py` | دسترسی مالک سامانه، گروهبندی، بستن/بازگشایی، audit |
|
||
|
||
**فرانتاند (`apps/web/src/test/`)** — ۲۵ تست: قرارداد API، کامپوننتهای پایه،
|
||
design system، و همبستگی `request_id` در کلاینت.
|
||
|
||
---
|
||
|
||
## ۹.۱ خط لولهٔ خطا
|
||
|
||
یک استثنا از محل وقوع تا کنسول چهار مرحله را طی میکند:
|
||
|
||
```
|
||
route raises
|
||
│
|
||
├─ RequestContextMiddleware → request_id + method/path/ip/user_agent روی request.state
|
||
├─ deps.get_current_user → user_id (پس از تأیید توکن)
|
||
├─ deps.get_org_context → organization_id (پس از تأیید عضویت)
|
||
│
|
||
├─ exception handler → انتخاب اینکه ثبت شود یا نه + ساخت پاسخ استاندارد
|
||
│ (errors.py) (۵xx همیشه، ۴xx فقط با ERROR_LOG_CLIENT_ERRORS)
|
||
│
|
||
├─ error_log.record_error → redact → truncate → fingerprint
|
||
│ (infrastructure) سشن مستقل خودش؛ هرگز استثنا پرتاب نمیکند
|
||
│
|
||
└─ ObservabilityRepository → INSERT در error_events
|
||
```
|
||
|
||
سه نکته که طراحی را تعیین کردهاند:
|
||
|
||
1. **recorder سشن خودش را باز میکند.** سشن درخواست در لحظهٔ فراخوانی معمولاً
|
||
غیرقابلاستفاده است: یک `IntegrityError` آن را در وضعیت
|
||
`PendingRollbackError` رها میکند. اگر از همان سشن استفاده میشد، سختترین
|
||
خطاها برای ثبت — خطاهای دیتابیس — هرگز ثبت نمیشدند.
|
||
2. **recorder هرگز پرتاب نمیکند.** یک خطای ثبتی نباید یک ۵۰۰ را به hang یا به
|
||
یک استثنای مدیریتنشده تبدیل کند.
|
||
3. **`X-Request-ID` در خود هندلر ست میشود، نه فقط در middleware.**
|
||
`ServerErrorMiddleware` استارلت بیرون از همهٔ middlewareهای کاربر است، پس
|
||
middleware هرگز پاسخی که برای یک استثنای مدیریتنشده ساخته میشود را
|
||
نمیبیند. این باگ واقعاً رخ داد و با
|
||
`test_error_responses_carry_the_header_for_every_handler` پوشش داده شد.
|
||
|
||
---
|
||
|
||
## ۱۰. اجرای پروژه
|
||
|
||
```bash
|
||
# ۱. زیرساخت
|
||
docker compose -f infra/docker/docker-compose.yml up -d postgres redis
|
||
|
||
# ۲. بکاند
|
||
cd apps/api
|
||
python3 -m venv .venv && .venv/bin/python -m pip install -e '.[dev]'
|
||
cp ../../.env.example ../../.env # مقادیر mock کافی است
|
||
.venv/bin/python -m alembic upgrade head # schema
|
||
.venv/bin/python -m app.seed # دادهٔ نمونه (provider/مدل/rate card/قالب)
|
||
|
||
# ۳. فرانتاند
|
||
cd ../.. && corepack pnpm install
|
||
corepack pnpm dev # web روی ۳۰۰۰، api روی ۸۰۰۰
|
||
```
|
||
|
||
**دستورهای کیفیت:**
|
||
|
||
```bash
|
||
pnpm lint # ESLint (web) + ruff (api)
|
||
pnpm typecheck # tsc --noEmit (web) + mypy app (api)
|
||
pnpm test # vitest (web) + pytest (api)
|
||
pnpm build # next build
|
||
```
|
||
|
||
---
|
||
|
||
## ۱۱. تصمیمهای معماری کلیدی
|
||
|
||
| تصمیم | دلیل |
|
||
| --- | --- |
|
||
| Modular monolith، نه microservice از روز اول | مرزهای domain از ابتدا روشناند؛ شکستن به سرویس بعداً ارزان است، اما پیچیدگی توزیع از روز اول گران است |
|
||
| یک adapter mock برای هر پورت | کل محصول بدون credential قابل اجرا و تست است؛ §۲۴ TODO را برآورده میکند |
|
||
| ۴۰۴ بهجای ۴۰۳ برای نبود عضویت | `403` وجود منبع را تأیید میکند و اجازهٔ شمارش میدهد |
|
||
| قواعد امنیتی در domain، نه infrastructure | بدون DB/شبکه قابل تستاند و از لایهٔ I/O جدا میمانند |
|
||
| clamp منابع دولایه (service + adapter) | defense in depth؛ خطای پیکربندی policy سقف مطلق را نمیشکند |
|
||
| usage ledger بهعنوان منبع حقیقت | محاسبهٔ صورتحساب از provider پرداخت جدا میشود |
|
||
| انتخاب adapter فقط با env، fail-closed | پیکربندی اشتباه باید بلند شکست بخورد، نه بیصدا به mock برگردد |
|
||
| `services/` فعلاً اسکلت است | `ai-runtime` و `billing-worker` فعلاً داخل فرآیند API اجرا میشوند؛ مرز پورتها آمادهٔ استخراج است |
|