Merge origin/dev: لایهٔ realtime (WebSocket/RPC) + فلو دعوت عضو

ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگ‌های صفحهٔ مدل‌ها) با ۸ کامیت محلی (ارائه‌دهندهٔ agy + رفع فلو دعوت).

تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.

راه‌حل:
- `app/main.py`: فقط `health` و `realtime_router` می‌مانند؛ ایمپورت‌های اضافی
  حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
  `GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
  قابل دسترس باشند.

`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد می‌شود،
پس هیچ تغییری در فراخوانی‌ها لازم نبود.

رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر می‌کرد و `useRealtime` استثنا می‌داد. حالا اسکلت
همان‌طور که در `app/layout.tsx` سیم‌کشی شده رندر می‌شود و سوکت در تست stub
شده است.

اعتبارسنجی روی درخت ادغام‌شده:
- بک‌اند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.

نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` می‌آیند و به این ادغام ربطی
ندارند؛ دست‌نخورده رها شدند.
This commit is contained in:
mjalmoudy94
2026-10-13 18:09:03 +03:30
49 changed files with 4370 additions and 294 deletions
@@ -215,6 +215,28 @@ async def create_model(
return _model_out(model)
@router.delete("/models/{model_id}", status_code=status.HTTP_200_OK)
async def delete_model(
model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict:
repo = AiModelRepository(session)
existing = await repo.get(model_id)
if existing is None:
raise NotFoundError("مدلی با این شناسه یافت نشد.")
await repo.delete(model_id)
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": existing.internal_name, "deleted": True},
ip=ip, user_agent=ua,
)
return {"ok": True}
# ---------------------------------------------------------------------------
# Routing policies
# ---------------------------------------------------------------------------
+25 -5
View File
@@ -105,6 +105,15 @@ class AgyTestOut(BaseModel):
output_tokens: int
class AgyAccountModelOut(BaseModel):
id: str
display_name: str
supports_thinking: bool = False
supported_efforts: list[str] = Field(default_factory=list)
default_effort: str | None = None
supports_images: bool = False
# ---------------------------------------------------------------------------
# Provider helpers
# ---------------------------------------------------------------------------
@@ -331,16 +340,16 @@ async def test_agy_account(
)
@router.get("/accounts/{credential_id}/models", response_model=list[str])
@router.get("/accounts/{credential_id}/models", response_model=list[AgyAccountModelOut])
async def list_account_models(
credential_id: str,
session: AsyncSession = Depends(get_session),
) -> list[str]:
) -> list[AgyAccountModelOut]:
"""Live model catalog of one account — fetched from Google on every call.
The admin console calls this when an account is selected, so the model
list always reflects that subscription's current entitlement instead of a
cached snapshot.
list always reflects that subscription's current entitlement along with
the supported reasoning efforts for each model.
"""
from app.core.errors import ValidationError
from app.infrastructure.adapters.agy import AgyModelAdapter
@@ -352,7 +361,18 @@ async def list_account_models(
adapter = AgyModelAdapter() # stateless: one forced account
try:
return await adapter.available_models_for(credential)
models = await adapter.available_models_for(credential)
return [
AgyAccountModelOut(
id=m.id,
display_name=m.display_name,
supports_thinking=m.supports_thinking,
supported_efforts=m.supported_efforts,
default_effort=m.default_effort,
supports_images=m.supports_images,
)
for m in models
]
except ValidationError:
raise
except Exception as exc: # noqa: BLE001 - the console wants the reason
@@ -42,5 +42,22 @@ async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
"sandbox": settings.sandbox_provider,
"payment": settings.payment_provider,
}
# Live-feed visibility: how many browser sockets this process is serving,
# and whether the cross-process relay (Redis) is connected. An operator
# wondering "is realtime working?" should not have to read the logs.
try:
from app.interfaces.realtime.bus import get_bus
from app.interfaces.realtime.router import realtime_manager
manager = realtime_manager()
checks["realtime"] = {
"connections": manager.connection_count,
"channels": manager.channel_count,
"redis_relay": "connected" if get_bus().redis_healthy else "in_process_only",
}
except Exception: # noqa: BLE001 - health must answer regardless
checks["realtime"] = "unavailable"
healthy = checks.get("database") == "ok"
return {"status": "ok" if healthy else "degraded", "checks": checks}