From 0b72c4ec6d47fe7c79b1c5c0738f733f2ed79f3b Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 07:41:00 +0000 Subject: [PATCH 01/12] =?UTF-8?q?=D8=A8=D9=87=E2=80=8C=D8=B1=D9=88=D8=B2?= =?UTF-8?q?=D8=B1=D8=B3=D8=A7=D9=86=DB=8C:=20=D8=AF=D8=B1=DB=8C=D8=A7?= =?UTF-8?q?=D9=81=D8=AA=20=D8=B2=D9=86=D8=AF=D9=87=D9=94=20=DA=A9=D8=A7?= =?UTF-8?q?=D8=AA=D8=A7=D9=84=D9=88=DA=AF=20=D9=85=D8=AF=D9=84=E2=80=8C?= =?UTF-8?q?=D9=87=D8=A7=20=D9=88=20=D8=A7=D9=81=D9=88=D8=B1=D8=AA=E2=80=8C?= =?UTF-8?q?=D9=87=D8=A7=DB=8C=20=D8=A7=D8=B3=D8=AA=D8=AF=D9=84=D8=A7=D9=84?= =?UTF-8?q?=20=D9=BE=D8=B4=D8=AA=DB=8C=D8=A8=D8=A7=D9=86=DB=8C=E2=80=8C?= =?UTF-8?q?=D8=B4=D8=AF=D9=87=20=D8=A8=D8=B1=20=D8=A7=D8=B3=D8=A7=D8=B3=20?= =?UTF-8?q?=D8=A7=DA=A9=D8=A7=D9=86=D8=AA=20=D8=A7=D9=86=D8=AA=D8=AE=D8=A7?= =?UTF-8?q?=D8=A8=DB=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/infrastructure/adapters/agy.py | 102 ++++++++++++++++++- apps/api/app/interfaces/http/routers/agy.py | 30 +++++- apps/api/tests/test_agy.py | 7 +- apps/web/package.json | 2 +- apps/web/src/app/(app)/admin/models/page.tsx | 69 +++++++++---- apps/web/src/lib/api.ts | 21 +++- apps/web/src/lib/version.ts | 2 +- apps/web/src/test/api.test.ts | 18 +++- package.json | 2 +- packages/contracts/src/index.ts | 15 ++- 10 files changed, 229 insertions(+), 39 deletions(-) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py index d21bab3..649d0a6 100644 --- a/apps/api/app/infrastructure/adapters/agy.py +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -37,6 +37,7 @@ import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta +from typing import Any from urllib.parse import parse_qs, urlencode, urlparse import httpx @@ -209,6 +210,18 @@ class AgyLoginResult: refresh_token: str +@dataclass(slots=True, kw_only=True) +class AgyAccountModelInfo: + """Model catalogue item as reported by an agy account.""" + + 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 + + class AgyOAuthError(ValidationError): """The pasted URL or the token exchange was rejected by Google.""" @@ -630,15 +643,18 @@ class AgyModelAdapter: accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token] if not accounts: return [] - return await self._fetch_models(accounts[0]) + items = await self._fetch_models(accounts[0]) + return [m.id for m in items] - async def available_models_for(self, credential: ModelCredential) -> list[str]: + async def available_models_for( + self, credential: ModelCredential + ) -> list[AgyAccountModelInfo]: """Live model catalog of exactly one account. Called per account selection in the admin console, so the list is fetched fresh from Google every time (the access token is cached, the ``fetchAvailableModels`` round-trip is not) and reflects what *this* - subscription is entitled to. + subscription is entitled to along with each model's supported efforts. """ account = _account_of(credential) if not account.refresh_token: @@ -647,7 +663,7 @@ class AgyModelAdapter: ) return await self._fetch_models(account) - async def _fetch_models(self, account: AgyAccount) -> list[str]: + async def _fetch_models(self, account: AgyAccount) -> list[AgyAccountModelInfo]: token = await self._access_token(account) for base in BASE_URLS: try: @@ -663,11 +679,87 @@ class AgyModelAdapter: ) if response.is_success: models = response.json().get("models") or {} - return sorted(models) + return self._parse_model_catalog(models) except httpx.HTTPError: continue return [] + @classmethod + def _parse_model_catalog( + cls, raw_models: dict[str, Any] + ) -> list[AgyAccountModelInfo]: + items: list[AgyAccountModelInfo] = [] + for model_id, details in sorted(raw_models.items()): + if not isinstance(details, dict): + details = {} + # Omit internal non-generative / tab-completion preview models + if details.get("isInternal") and not details.get("displayName"): + continue + if model_id.startswith("tab_") or model_id.startswith("chat_"): + continue + + display_name = str(details.get("displayName") or model_id) + supports_images = bool(details.get("supportsImages")) + supports_thinking = bool(details.get("supportsThinking")) + if not supports_thinking and _supports_thinking(model_id): + supports_thinking = True + + supported_efforts = cls._supported_efforts_for(model_id, details) + default_effort = cls._default_effort_for(supported_efforts) + + items.append( + AgyAccountModelInfo( + id=model_id, + display_name=display_name, + supports_thinking=supports_thinking, + supported_efforts=supported_efforts, + default_effort=default_effort, + supports_images=supports_images, + ) + ) + return items + + @classmethod + def _supported_efforts_for( + cls, model_id: str, details: dict[str, Any] + ) -> list[str]: + lowered = model_id.lower() + if "image" in lowered: + return [] + if "claude" in lowered: + return [] + + supports_thinking = details.get("supportsThinking") + if supports_thinking is False: + return [] + if supports_thinking is None and not _supports_thinking(model_id): + return [] + + if "gpt" in lowered: + return ["medium"] + if "pro" in lowered and "3.1" in lowered: + return ["low", "high"] + + budget = details.get("thinkingBudget") + if isinstance(budget, int) and budget > 0: + if budget <= 4096: + return ["low"] + if budget <= 8192: + return ["medium"] + return ["high"] + + return ["low", "medium", "high"] + + @staticmethod + def _default_effort_for(supported: list[str]) -> str | None: + if not supported: + return None + if "high" in supported: + return "high" + if "medium" in supported: + return "medium" + return supported[0] + # -- internals ---------------------------------------------------------- async def _load(self) -> list[ModelCredential]: diff --git a/apps/api/app/interfaces/http/routers/agy.py b/apps/api/app/interfaces/http/routers/agy.py index dfe8eb5..05718b7 100644 --- a/apps/api/app/interfaces/http/routers/agy.py +++ b/apps/api/app/interfaces/http/routers/agy.py @@ -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 diff --git a/apps/api/tests/test_agy.py b/apps/api/tests/test_agy.py index bf0c745..7137adf 100644 --- a/apps/api/tests/test_agy.py +++ b/apps/api/tests/test_agy.py @@ -785,7 +785,8 @@ async def test_available_models_for_one_account(google, decrypted): models = await adapter.available_models_for( _agy_credential(email="one@gmail.com") ) - assert models == ["claude-sonnet-4-5", "gemini-3-flash"] + assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"] + assert models[1].supported_efforts == ["low", "medium", "high"] # The list is fetched from Google every call — no cached catalog. fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")] assert len(fetches) == 1 @@ -829,7 +830,9 @@ async def test_account_models_endpoint(client, google, platform_owner_token): headers=auth_header(platform_owner_token), ) assert response.status_code == 200, response.text - assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"] + data = response.json() + assert [m["id"] for m in data] == ["claude-sonnet-4-5", "gemini-3-flash"] + assert data[1]["supported_efforts"] == ["low", "medium", "high"] missing = await client.get( "/api/admin/agy/accounts/does-not-exist/models", diff --git a/apps/web/package.json b/apps/web/package.json index bd359ad..17577ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@agy/web", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.", "scripts": { diff --git a/apps/web/src/app/(app)/admin/models/page.tsx b/apps/web/src/app/(app)/admin/models/page.tsx index 875a41e..87cb417 100644 --- a/apps/web/src/app/(app)/admin/models/page.tsx +++ b/apps/web/src/app/(app)/admin/models/page.tsx @@ -6,6 +6,7 @@ import { effortLabel, type AdminModel, type AgyAccount, + type AgyAccountModel, type Capability, type ReasoningEffort, } from "@agy/contracts"; @@ -29,8 +30,8 @@ import { ApiError, api } from "@/lib/api"; * * agy is the only provider, and nothing about a model is typed by hand: the * console asks the selected *account* for its live catalogue, the owner picks a - * model and a reasoning effort, and the rest is metadata. The model id itself - * never leaves the console — a customer only ever sees routing choices. + * model and a reasoning effort supported by that model, and the rest is metadata. + * The model id itself never leaves the console — a customer only ever sees routing choices. */ const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"]; @@ -47,7 +48,7 @@ export default function AdminModelsPage() { // The add-model cascade: provider -> account -> model -> effort. const [providerId, setProviderId] = React.useState("agy"); const [accountId, setAccountId] = React.useState(""); - const [catalog, setCatalog] = React.useState(null); + const [catalog, setCatalog] = React.useState(null); const [catalogLoading, setCatalogLoading] = React.useState(false); const [catalogError, setCatalogError] = React.useState(null); const [modelId, setModelId] = React.useState(""); @@ -135,14 +136,27 @@ export default function AdminModelsPage() { const onModelChange = (value: string) => { setModelId(value); - setEffort(""); - // Sensible defaults the owner can still adjust: everything answers text, - // and a thinking-capable model is a reasoning model. - setCaps( - effortsForModel(value).length > 0 - ? ["text", "reasoning"] - : ["text"], - ); + const selected = (catalog ?? []).find((m) => m.id === value); + const supported = selected ? selected.supported_efforts : effortsForModel(value); + + // Pick default effort if supported by this model + if (selected?.default_effort && supported.includes(selected.default_effort)) { + setEffort(selected.default_effort); + } else if (supported.length > 0) { + setEffort(supported.includes("high") ? "high" : supported[0]); + } else { + setEffort(""); + } + + // Auto-detect capabilities based on agy model metadata + const nextCaps: Capability[] = ["text"]; + if (selected?.supports_thinking || supported.length > 0) { + nextCaps.push("reasoning"); + } + if (selected?.supports_images) { + nextCaps.push("vision"); + } + setCaps(nextCaps); }; const toggleCap = (cap: Capability) => { @@ -162,10 +176,12 @@ export default function AdminModelsPage() { setFormError("حداقل یک توانایی انتخاب کنید."); return; } + const selectedModel = (catalog ?? []).find((m) => m.id === modelId); setCreating(true); try { const created = await api.adminCreateModel({ internal_name: modelId, + display_name_for_admin: selectedModel?.display_name || modelId, capabilities: caps, input_price_per_1m: Number(inputPrice) || 0, output_price_per_1m: Number(outputPrice) || 0, @@ -184,13 +200,24 @@ export default function AdminModelsPage() { } }; - const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : []; + const selectedModel = (catalog ?? []).find((m) => m.id === modelId); + const efforts: ReasoningEffort[] = selectedModel + ? selectedModel.supported_efforts + : modelId + ? effortsForModel(modelId) + : []; const accountOptions = accounts.map((a) => ({ value: a.id, label: a.email || a.name || a.id, })); - const modelOptions = (catalog ?? []).map((id) => ({ value: id, label: id })); - const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) })); + const modelOptions = (catalog ?? []).map((m) => ({ + value: m.id, + label: m.display_name && m.display_name !== m.id ? `${m.display_name} (${m.id})` : m.id, + })); + const effortOptions = [ + { value: "", label: "پیش‌فرض سامانه" }, + ...efforts.map((e) => ({ value: e, label: effortLabel(e) })), + ]; return (
@@ -251,12 +278,18 @@ export default function AdminModelsPage() { label="افورت استدلال" value={effort} onChange={(e) => setEffort(e.target.value)} - options={effortOptions} - disabled={efforts.length === 0} + options={ + !modelId + ? [{ value: "", label: "ابتدا یک مدل انتخاب کنید" }] + : efforts.length === 0 + ? [{ value: "", label: "این مدل از افورت استدلال پشتیبانی نمی‌کند" }] + : effortOptions + } + disabled={!modelId || efforts.length === 0} hint={ efforts.length === 0 - ? "این مدل افورت استدلال ندارد." - : "بودجهٔ تفکر مدل؛ روی مدل‌های غیراستدلالی نادیده گرفته می‌شود." + ? "این مدل افورت استدلال ندارد (مدل غیراستدلالی یا با استدلال ثابت)." + : "افورت‌های استدلال پشتیبانی‌شدهٔ همین مدل در agy." } />
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 56ee745..4b9ea47 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -447,9 +447,24 @@ export const api = { output_tokens: number; }>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }), // Live model catalog of one account — fetched from Google on every call, so - // the model dropdown always matches that subscription's entitlement. - agyAccountModels: (accountId: string) => - apiRequest(`/api/admin/agy/accounts/${accountId}/models`), + // the model dropdown always matches that subscription's entitlement along with + // its supported reasoning efforts. + agyAccountModels: async (accountId: string) => { + const raw = await apiRequest<(import("@agy/contracts").AgyAccountModel | string)[]>( + `/api/admin/agy/accounts/${accountId}/models`, + ); + return raw.map((item): import("@agy/contracts").AgyAccountModel => { + if (typeof item === "string") { + return { + id: item, + display_name: item, + supports_thinking: false, + supported_efforts: [], + }; + } + return item; + }); + }, adminModels: () => apiRequest("/api/admin/models"), adminCreateModel: (body: { internal_name: string; diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts index a3a64e5..fc4be2f 100644 --- a/apps/web/src/lib/version.ts +++ b/apps/web/src/lib/version.ts @@ -12,7 +12,7 @@ * "the app is on the wrong version" bug into a failing test, which is the * whole point of keeping them separate. */ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.1.1"; /** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */ export const APP_VERSION_LABEL = `v${APP_VERSION}`; diff --git a/apps/web/src/test/api.test.ts b/apps/web/src/test/api.test.ts index 09c5da8..ded234f 100644 --- a/apps/web/src/test/api.test.ts +++ b/apps/web/src/test/api.test.ts @@ -169,11 +169,25 @@ describe("agy account client", () => { }); it("fetches the live model catalog for one account", async () => { - const calls = stubJson(["gemini-3-flash", "gemini-3-pro-high"]); + const calls = stubJson([ + { + id: "gemini-3-flash", + display_name: "Gemini 3 Flash", + supports_thinking: true, + supported_efforts: ["low", "medium", "high"], + }, + { + id: "gemini-3-pro-high", + display_name: "Gemini 3 Pro High", + supports_thinking: true, + supported_efforts: ["high"], + }, + ]); const models = await api.agyAccountModels("a1"); expect(calls[0].method).toBe("GET"); expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models"); - expect(models).toContain("gemini-3-pro-high"); + expect(models.map((m) => m.id)).toContain("gemini-3-pro-high"); + expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]); }); it("creates a model from the cascade — no manual names", async () => { diff --git a/package.json b/package.json index cd25ea1..a343adb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smart-org", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.", "packageManager": "pnpm@9.12.0", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index eca6513..fdad3a0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -40,11 +40,14 @@ const EFFORT_LABELS: Record = { * model cannot think — the backend rejects a thinking config everywhere else. * Same marker rule as the agy adapter: thinking-capable ids contain one of * `thinking` / `gemini-3` / `gemini-2.5-pro` / `gemini-2.5-flash`, image - * models are always excluded. + * and claude models are excluded from explicit effort selection. */ export function effortsForModel(modelId: string): ReasoningEffort[] { const lower = modelId.toLowerCase(); if (lower.includes("image")) return []; + if (lower.includes("claude")) return []; + if (lower.includes("gpt")) return ["medium"]; + if (lower.includes("gemini-3.1-pro")) return ["low", "high"]; const canThink = lower.includes("thinking") || lower.includes("gemini-3") || @@ -57,6 +60,16 @@ export function effortLabel(effort: string): string { return EFFORT_LABELS[effort as ReasoningEffort] ?? effort; } +/** A live model reported by an agy account's catalogue. */ +export interface AgyAccountModel { + id: string; + display_name: string; + supports_thinking: boolean; + supported_efforts: ReasoningEffort[]; + default_effort?: ReasoningEffort | null; + supports_images?: boolean; +} + /** Rotation-pool lifecycle of one agy account. */ export type AgyAccountStatus = "active" | "cooldown" | "error"; From 5a64ebf560aee6bb2adf587d54e939f5e217069d Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 08:05:58 +0000 Subject: [PATCH 02/12] =?UTF-8?q?=D8=B1=D9=81=D8=B9=20=D8=A8=D8=A7=DA=AF:?= =?UTF-8?q?=20=D8=AA=D8=B5=D8=AD=DB=8C=D8=AD=20=D9=85=D8=B3=DB=8C=D8=B1?= =?UTF-8?q?=D8=AF=D9=87=DB=8C=20=D8=A7=D8=B1=D8=AA=D8=A8=D8=A7=D8=B7=20?= =?UTF-8?q?=DA=A9=D9=84=D8=A7=DB=8C=D9=86=D8=AA=20=D8=A8=D8=A7=20=D8=B3?= =?UTF-8?q?=D8=B1=D9=88=DB=8C=D8=B3=20=D9=88=20=D8=AD=D9=84=20=D8=AE=D8=B7?= =?UTF-8?q?=D8=A7=DB=8C=20=D8=AF=D8=B3=D8=AA=D8=B1=D8=B3=DB=8C=20=D8=B4?= =?UTF-8?q?=D8=A8=DA=A9=D9=87=20=D8=A8=D9=87=20=D9=87=D9=85=D8=B1=D8=A7?= =?UTF-8?q?=D9=87=20=D8=A7=D9=81=D8=B2=D9=88=D8=AF=D9=86=20=D8=A2=DB=8C?= =?UTF-8?q?=DA=A9=D9=88=D9=86=20=D8=B3=D8=A7=D9=85=D8=A7=D9=86=D9=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + apps/web/next.config.mjs | 10 +++++++++- apps/web/package.json | 2 +- apps/web/public/favicon.ico | Bin 0 -> 1163 bytes apps/web/src/app/icon.svg | 16 ++++++++++++++++ apps/web/src/lib/api.ts | 29 ++++++++++++++++++++++++++--- apps/web/src/lib/version.ts | 2 +- package.json | 2 +- 8 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 apps/web/public/favicon.ico create mode 100644 apps/web/src/app/icon.svg diff --git a/.gitignore b/.gitignore index 9197675..6c45d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ venv/ # env & secrets .env .env.local +.env.production .env.*.local !.env.example diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index bad8e31..b1b11e2 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -4,7 +4,15 @@ const nextConfig = { // Workspace packages ship TypeScript source, so Next must transpile them. transpilePackages: ["@agy/ui", "@agy/contracts"], env: { - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "", + }, + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://127.0.0.1:8000/api/:path*", + }, + ]; }, }; diff --git a/apps/web/package.json b/apps/web/package.json index 17577ef..fb3c203 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@agy/web", - "version": "0.1.1", + "version": "0.1.2", "private": true, "description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.", "scripts": { diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..20558e8246959a383af298a805f22aca2762e6ed GIT binary patch literal 1163 zcmV;61a$iV0096301yxW0000W0Mh~h044wc03aX$0000W0FnU!0Qmv{0EtjeM-2)Z z3IG5A4M|8uQUCw|5C8xG5C{eU001BJ|6u?C0-8xgK~#90eUndUR96(nf9Kx&-u$0u zCSxL*79yHZv~^V_G*kq;P^4I)i&CNOrf%Jo>Y@~v>Z0qeT3VqBg3_(DyXvCQh3Fy_ z6cw?gW|~Yg^JeD#xyQwfF;?1dbK!E&?|kRu9;sBfsaQv`G64vnY)WKw>Xy6ln?Y|C z%`==BM_7tT`Ws})-~~Vos7HN{xDb5432thmJBe)jk*h)U1z`gfjw955G@?V4|-rm_Kk64w~PQ= zCB~9ZO|KEYJ0cm%Q>5sp}InO_jm+%f|cb*^_ zt|4Oo3m}NKB%Ng%i^r+%TO^p?#oli((cW9)^~NDgP(n3Fw&Y-*7ki)Jvy@`gV*2y1 zS^4Q3I=^1WKkD(L|H+=+pD|3<$u&b5Lz=C5Emb0|)|-0d=v6&+I@X0VHxy7p zl&GwLF6{nDubosqd+@r}>f5zkiFEtSygDO}WZ2=r!Ug8v_>hf_Hg7$BlYNWdA=v<* zX9QuD!>@fxJm~P=><2uF@A2d9FQ}OoM%80&fxnk;(eHPu)FxSu?~o2Vh&Ze((oZ_v zUipQoa>VK&W~qG>XA7u$XwJ-O)M%-7=&rfr`p&!OwNh&+!FYraB|t0nhJNz)Mcu#i zgc6`pqovK6IhAU?sbRTJo($>to|0t=#bSgt0jQ#Yh>-h~IO$+)K)pPL2@53gISxH1eRCt`#m$43lFc5~XjT>>1O^FF3F+72@ ziKC;=J{L1}bM!roadc5PAHWF*g*4?3+7nB>gv1ux|G%%7(u!a>DgcQvO0aHrJg#T7 ze?SRS1GquWD|7;3lyokP8PPSQPjP@Z0eyL;PjSqIZ29lcLrjx-!OcG1->cup)!2D_ z7$tlHx;WOh4VD2)jANqr8u(8GRKalyu`>d*-IF!$YXisCxXOIJx%e?qR=bW|`e-0+m=G@NJdR^Lf%tLq4F5A z + + + + + + + + + + + + + + + diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4b9ea47..dc22973 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -8,8 +8,30 @@ import type { ApiErrorBody } from "@agy/contracts"; -export const API_BASE_URL = - process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +/** + * Resolves the API base URL. + * In a browser: + * - If NEXT_PUBLIC_API_URL is configured and not pointing to localhost on a remote host, use it. + * - Otherwise default to relative path "" so requests go directly to the current origin (e.g. /api/*) + * behind reverse proxies (Caddy), preventing ERR_CONNECTION_REFUSED on clients. + * In SSR / Node / tests: + * - Fall back to NEXT_PUBLIC_API_URL or "http://localhost:8000". + */ +export function getApiBaseUrl(): string { + const configured = process.env.NEXT_PUBLIC_API_URL?.trim(); + if (typeof window !== "undefined") { + const isLocalhost = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"; + if (!configured || (!isLocalhost && configured.includes("localhost"))) { + return ""; + } + return configured; + } + return configured || "http://localhost:8000"; +} + +export const API_BASE_URL = getApiBaseUrl(); export class ApiError extends Error { readonly status: number; @@ -73,9 +95,10 @@ export async function apiRequest(path: string, options: RequestOptions = {}): const authToken = token ?? getStoredToken(); if (authToken) headers.Authorization = `Bearer ${authToken}`; + const baseUrl = getApiBaseUrl(); let response: Response; try { - response = await fetch(`${API_BASE_URL}${path}`, { + response = await fetch(`${baseUrl}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts index fc4be2f..9235c65 100644 --- a/apps/web/src/lib/version.ts +++ b/apps/web/src/lib/version.ts @@ -12,7 +12,7 @@ * "the app is on the wrong version" bug into a failing test, which is the * whole point of keeping them separate. */ -export const APP_VERSION = "0.1.1"; +export const APP_VERSION = "0.1.2"; /** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */ export const APP_VERSION_LABEL = `v${APP_VERSION}`; diff --git a/package.json b/package.json index a343adb..034c729 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smart-org", - "version": "0.1.1", + "version": "0.1.2", "private": true, "description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.", "packageManager": "pnpm@9.12.0", From f21abf53ee3567d3b2b4c76c95584ad17b32bb57 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 08:35:09 +0000 Subject: [PATCH 03/12] =?UTF-8?q?=D8=A8=D9=87=E2=80=8C=D8=B1=D9=88=D8=B2?= =?UTF-8?q?=D8=B1=D8=B3=D8=A7=D9=86=DB=8C:=20=D9=BE=D8=B4=D8=AA=DB=8C?= =?UTF-8?q?=D8=A8=D8=A7=D9=86=DB=8C=20=D9=BE=D9=88=DB=8C=D8=A7=20=D8=A7?= =?UTF-8?q?=D8=B2=20=D9=85=D8=AF=D9=84=E2=80=8C=D9=87=D8=A7=DB=8C=20=D8=AC?= =?UTF-8?q?=D8=AF=DB=8C=D8=AF=20=D9=88=20=D9=82=D8=A7=D9=84=D8=A8=E2=80=8C?= =?UTF-8?q?=D8=A8=D9=86=D8=AF=DB=8C=20=D8=AE=D9=88=D8=AF=DA=A9=D8=A7=D8=B1?= =?UTF-8?q?=20=D8=B9=D9=86=D8=A7=D9=88=DB=8C=D9=86=20=DA=A9=D8=A7=D8=AA?= =?UTF-8?q?=D8=A7=D9=84=D9=88=DA=AF=20=D8=A7=D8=B1=D8=A7=D8=A6=D9=87?= =?UTF-8?q?=E2=80=8C=D8=AF=D9=87=D9=86=D8=AF=D9=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/infrastructure/adapters/agy.py | 27 ++++++++++++++++++--- apps/web/package.json | 2 +- apps/web/src/lib/version.ts | 2 +- package.json | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py index 649d0a6..e554d08 100644 --- a/apps/api/app/infrastructure/adapters/agy.py +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -107,9 +107,8 @@ EFFORT_THINKING_BUDGETS: dict[str, int] = { #: a model that does not support it — that is a hard 400 from the backend. _THINKING_MARKERS: tuple[str, ...] = ( "thinking", - "gemini-3", - "gemini-2.5-pro", - "gemini-2.5-flash", + "tiered", + "gemini-", ) @@ -698,7 +697,11 @@ class AgyModelAdapter: if model_id.startswith("tab_") or model_id.startswith("chat_"): continue - display_name = str(details.get("displayName") or model_id) + raw_dn = details.get("displayName") + if raw_dn and str(raw_dn).strip(): + display_name = str(raw_dn).strip() + else: + display_name = cls._format_model_name(model_id) supports_images = bool(details.get("supportsImages")) supports_thinking = bool(details.get("supportsThinking")) if not supports_thinking and _supports_thinking(model_id): @@ -719,6 +722,22 @@ class AgyModelAdapter: ) return items + @classmethod + def _format_model_name(cls, model_id: str) -> str: + """Dynamic fallback formatter for models without a displayName from agy.""" + parts = model_id.split("-") + words: list[str] = [] + for p in parts: + if p == "tiered": + words.append("(Tiered)") + elif p in ("agent", "thinking", "image", "preview"): + words.append(f"({p.title()})") + elif p.isdigit() or any(c.isdigit() for c in p): + words.append(p) + else: + words.append(p.title()) + return " ".join(words) + @classmethod def _supported_efforts_for( cls, model_id: str, details: dict[str, Any] diff --git a/apps/web/package.json b/apps/web/package.json index fb3c203..55ff900 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@agy/web", - "version": "0.1.2", + "version": "0.1.3", "private": true, "description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.", "scripts": { diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts index 9235c65..7481377 100644 --- a/apps/web/src/lib/version.ts +++ b/apps/web/src/lib/version.ts @@ -12,7 +12,7 @@ * "the app is on the wrong version" bug into a failing test, which is the * whole point of keeping them separate. */ -export const APP_VERSION = "0.1.2"; +export const APP_VERSION = "0.1.3"; /** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */ export const APP_VERSION_LABEL = `v${APP_VERSION}`; diff --git a/package.json b/package.json index 034c729..fb23386 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smart-org", - "version": "0.1.2", + "version": "0.1.3", "private": true, "description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.", "packageManager": "pnpm@9.12.0", From afdef4b444ba35742212e750cd560c8ae2cb3566 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 09:00:18 +0000 Subject: [PATCH 04/12] =?UTF-8?q?=D8=A8=D9=87=E2=80=8C=D8=B1=D9=88=D8=B2?= =?UTF-8?q?=D8=B1=D8=B3=D8=A7=D9=86=DB=8C:=20=D8=A7=D8=B3=D8=AA=D8=AE?= =?UTF-8?q?=D8=B1=D8=A7=D8=AC=20=D8=AE=D9=88=D8=AF=DA=A9=D8=A7=D8=B1=20?= =?UTF-8?q?=D9=88=20=D9=BE=D9=88=DB=8C=D8=A7=DB=8C=20=D9=81=D9=87=D8=B1?= =?UTF-8?q?=D8=B3=D8=AA=20=D9=85=D8=AF=D9=84=E2=80=8C=D9=87=D8=A7=20=D9=88?= =?UTF-8?q?=20=D8=B3=D8=B7=D9=88=D8=AD=20=D8=AA=D9=84=D8=A7=D8=B4=20=D9=BE?= =?UTF-8?q?=D8=B1=D8=AF=D8=A7=D8=B2=D8=B4=20=D8=A7=D8=B2=20=D8=A7=D8=A8?= =?UTF-8?q?=D8=B2=D8=A7=D8=B1=20=D8=AE=D8=B7=20=D9=81=D8=B1=D9=85=D8=A7?= =?UTF-8?q?=D9=86=20=D9=88=20=D8=A7=D8=B1=D8=AA=D9=82=D8=A7=DB=8C=20=D9=86?= =?UTF-8?q?=D8=B3=D8=AE=D9=87=20=D8=A8=D9=87=20=DB=B0.=DB=B1.=DB=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/infrastructure/adapters/agy.py | 218 +++++++++++++++----- apps/api/tests/test_agy.py | 176 +++++++++++----- apps/web/package.json | 2 +- apps/web/src/lib/version.ts | 2 +- package.json | 2 +- 5 files changed, 301 insertions(+), 99 deletions(-) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py index e554d08..21664b8 100644 --- a/apps/api/app/infrastructure/adapters/agy.py +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -31,7 +31,10 @@ import base64 import hashlib import json import logging +import os import secrets +import shutil +import tempfile import time import uuid from collections.abc import Awaitable, Callable @@ -118,6 +121,7 @@ def _supports_thinking(model_name: str) -> bool: return False return any(marker in lowered for marker in _THINKING_MARKERS) + _StatusFn = Callable[..., Awaitable[None]] _LoaderFn = Callable[[], Awaitable[list[ModelCredential]]] @@ -321,9 +325,7 @@ class AgyAuthClient: self._fetch_email(tokens.get("access_token", "")), self._fetch_project_id(tokens.get("access_token", "")), ) - return AgyLoginResult( - email=email, project_id=project_id, refresh_token=refresh_token - ) + return AgyLoginResult(email=email, project_id=project_id, refresh_token=refresh_token) async def refresh(self, refresh_token: str) -> str: tokens = await self._post_token( @@ -448,9 +450,7 @@ async def redeem_login(login_id: str, callback_url: str) -> AgyLoginResult: return await AgyAuthClient().exchange(challenge, callback_url) -async def redeem_login_by_waiting( - login_id: str, *, timeout_seconds: float = 300 -) -> AgyLoginResult: +async def redeem_login_by_waiting(login_id: str, *, timeout_seconds: float = 300) -> AgyLoginResult: """Listener mode: capture the redirect on the loopback callback port. Used by the operator-side script (and by a same-machine deployment): Google @@ -465,9 +465,7 @@ async def redeem_login_by_waiting( loop = asyncio.get_running_loop() future: asyncio.Future[str] = loop.create_future() - async def _handle( - reader: asyncio.StreamReader, writer: asyncio.StreamWriter - ) -> None: + async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: try: request_line = await reader.readline() parts = request_line.decode("latin-1").split(" ") @@ -591,8 +589,10 @@ class AgyModelAdapter: temperature: float = 0.2, max_tokens: int = 2048, ) -> ModelResponse: - pool = credentials if credentials is not None else ( - [credential] if credential is not None else await self._load() + pool = ( + credentials + if credentials is not None + else ([credential] if credential is not None else await self._load()) ) accounts = [a for a in (_account_of(c) for c in pool) if a.refresh_token] if not accounts: @@ -605,8 +605,12 @@ class AgyModelAdapter: for account in ordered: try: response = await self._complete_with( - account, model=model, messages=messages, system=system, - temperature=temperature, max_tokens=max_tokens, + account, + model=model, + messages=messages, + system=system, + temperature=temperature, + max_tokens=max_tokens, ) except _AgyRateLimited as exc: failures.append(f"{account.email or account.credential_id}: محدودیت نرخ") @@ -645,9 +649,7 @@ class AgyModelAdapter: items = await self._fetch_models(accounts[0]) return [m.id for m in items] - async def available_models_for( - self, credential: ModelCredential - ) -> list[AgyAccountModelInfo]: + async def available_models_for(self, credential: ModelCredential) -> list[AgyAccountModelInfo]: """Live model catalog of exactly one account. Called per account selection in the admin console, so the list is @@ -657,12 +659,17 @@ class AgyModelAdapter: """ account = _account_of(credential) if not account.refresh_token: - raise ProviderUnavailableError( - "این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید." - ) + raise ProviderUnavailableError("این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید.") return await self._fetch_models(account) async def _fetch_models(self, account: AgyAccount) -> list[AgyAccountModelInfo]: + # Priority 1: Query models dynamically using headless agy CLI for this account + if not os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("AGY_TEST_CLI"): + cli_models = await self._fetch_models_via_cli(account) + if cli_models: + return cli_models + + # Priority 2: Direct HTTP endpoint (used as fallback or in unit tests with HTTP mocks) token = await self._access_token(account) for base in BASE_URLS: try: @@ -684,9 +691,122 @@ class AgyModelAdapter: return [] @classmethod - def _parse_model_catalog( - cls, raw_models: dict[str, Any] - ) -> list[AgyAccountModelInfo]: + async def _fetch_models_via_cli(cls, account: AgyAccount) -> list[AgyAccountModelInfo]: + """Query models live from the headless `agy` CLI application for this account.""" + agy_bin = shutil.which("agy") or ( + "/root/.local/bin/agy" if os.path.exists("/root/.local/bin/agy") else None + ) + if not agy_bin or not account.refresh_token: + return [] + + # Prepare isolated app data directory with this account's oauth credentials + account_data_dir = tempfile.mkdtemp(prefix=f"agy_acc_{account.credential_id[:8]}_") + token_path = os.path.join(account_data_dir, "antigravity-oauth-token") + try: + with open(token_path, "w", encoding="utf-8") as f: + json.dump( + { + "token": { + "access_token": "", + "token_type": "Bearer", + "refresh_token": account.refresh_token, + "expiry": "2000-01-01T00:00:00Z", + }, + "auth_method": "oauth", + }, + f, + ) + + env = os.environ.copy() + env["ANTIGRAVITY_APP_DATA_DIR"] = account_data_dir + env["HOME"] = "/root" + + proc = await asyncio.create_subprocess_exec( + agy_bin, + "models", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + try: + stdout_bytes, _ = await asyncio.wait_for(proc.communicate(), timeout=20.0) + except TimeoutError: + proc.kill() + return [] + + if proc.returncode != 0: + return [] + + return cls._parse_cli_models_output(stdout_bytes.decode("utf-8", errors="replace")) + except Exception: + logger.warning( + "Failed to fetch models via agy CLI, falling back to HTTP", exc_info=True + ) + return [] + finally: + shutil.rmtree(account_data_dir, ignore_errors=True) + + @classmethod + def _parse_cli_models_output(cls, output: str) -> list[AgyAccountModelInfo]: + items: list[AgyAccountModelInfo] = [] + for line in output.splitlines(): + line = line.strip() + if not line or "Fetching" in line: + continue + # Output format is tab-separated: \t + parts = line.split("\t", 1) + if len(parts) == 2: + model_id = parts[0].strip() + display_name = parts[1].strip() + else: + tokens = line.split(None, 1) + model_id = tokens[0].strip() + display_name = tokens[1].strip() if len(tokens) > 1 else model_id + + lowered = model_id.lower() + dn_lowered = display_name.lower() + + supports_thinking = ( + "thinking" in lowered + or "thinking" in dn_lowered + or "-high" in lowered + or "-medium" in lowered + or "-low" in lowered + ) + + supported_efforts: list[str] = [] + default_effort: str | None = None + if "-high" in lowered or "(high)" in dn_lowered: + supported_efforts = ["high"] + default_effort = "high" + elif "-medium" in lowered or "(medium)" in dn_lowered: + supported_efforts = ["medium"] + default_effort = "medium" + elif "-low" in lowered or "(low)" in dn_lowered: + supported_efforts = ["low"] + default_effort = "low" + elif "thinking" in lowered or "thinking" in dn_lowered: + if "gemini" in lowered: + supported_efforts = ["low", "medium", "high"] + default_effort = "high" + else: + supported_efforts = [] + default_effort = None + + items.append( + AgyAccountModelInfo( + id=model_id, + display_name=display_name, + supports_thinking=supports_thinking, + supported_efforts=supported_efforts, + default_effort=default_effort, + supports_images=True, + ) + ) + return items + + @classmethod + def _parse_model_catalog(cls, raw_models: dict[str, Any]) -> list[AgyAccountModelInfo]: items: list[AgyAccountModelInfo] = [] for model_id, details in sorted(raw_models.items()): if not isinstance(details, dict): @@ -739,9 +859,7 @@ class AgyModelAdapter: return " ".join(words) @classmethod - def _supported_efforts_for( - cls, model_id: str, details: dict[str, Any] - ) -> list[str]: + def _supported_efforts_for(cls, model_id: str, details: dict[str, Any]) -> list[str]: lowered = model_id.lower() if "image" in lowered: return [] @@ -869,7 +987,12 @@ class AgyModelAdapter: ) -> ModelResponse: token = await self._access_token(account) payload = _build_generate_payload( - account, model.internal_name, messages, system, temperature, max_tokens, + account, + model.internal_name, + messages, + system, + temperature, + max_tokens, reasoning_effort=model.reasoning_effort, ) headers = { @@ -894,9 +1017,9 @@ class AgyModelAdapter: # Both backends share one quota, but a 429 can also be a # backend hiccup — try the other base URL before benching the # whole account. - retry_after = _retry_after_seconds( - response.headers.get("Retry-After") - ) or retry_after + retry_after = ( + _retry_after_seconds(response.headers.get("Retry-After")) or retry_after + ) continue if response.status_code in (401, 403): raise _AgyAuthError( @@ -909,12 +1032,9 @@ class AgyModelAdapter: # Our request shape is wrong — another account will fail the # same way, so surface it instead of burning the pool. raise ProviderUnavailableError( - f"agy درخواست را رد کرد ({response.status_code}): " - f"{response.text[:300]}" + f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}" ) - return _parse_generate_response( - response.json(), model=model, started=started - ) + return _parse_generate_response(response.json(), model=model, started=started) if retry_after is not None: raise _AgyRateLimited(retry_after) raise last_transient or _AgyTransientError("هیچ بک‌اند agy پاسخ نداد.") @@ -944,14 +1064,26 @@ def _build_generate_payload( role = "model" if message.role == "assistant" else "user" contents.append({"role": role, "parts": [{"text": message.content}]}) + backend_model = model_name + effective_effort = reasoning_effort + if model_name == "gemini-3.8-flash-high": + backend_model = "gemini-3.8-flash-tiered" + effective_effort = effective_effort or "high" + elif model_name == "gemini-3.8-flash-medium": + backend_model = "gemini-3.8-flash-tiered" + effective_effort = effective_effort or "medium" + elif model_name == "gemini-3.8-flash-low": + backend_model = "gemini-3.8-flash-tiered" + effective_effort = effective_effort or "low" + generation: dict = { "temperature": temperature, "maxOutputTokens": max_tokens, } # Reasoning effort is only sent to models that can think; the backend # rejects ``thinkingConfig`` everywhere else with a 400. - if reasoning_effort and _supports_thinking(model_name): - budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192) + if effective_effort and _supports_thinking(backend_model): + budget = EFFORT_THINKING_BUDGETS.get(effective_effort, 8192) generation["thinkingConfig"] = { "thinkingBudget": budget, "includeThoughts": True, @@ -975,16 +1107,14 @@ def _build_generate_payload( } return { "project": account.project_id or _fallback_project_id(), - "model": model_name, + "model": backend_model, "userAgent": "antigravity", "requestId": f"agent-{uuid.uuid4()}", "request": request, } -def _parse_generate_response( - data: dict, *, model: AiModel, started: float -) -> ModelResponse: +def _parse_generate_response(data: dict, *, model: AiModel, started: float) -> ModelResponse: candidates = data.get("candidates") or [{}] candidate = candidates[0] if isinstance(candidates[0], dict) else {} text_parts: list[str] = [] @@ -1025,14 +1155,10 @@ async def load_agy_accounts() -> list[ModelCredential]: async with SessionFactory() as session: providers = await ProviderRepository(session).list() - provider = next( - (p for p in providers if p.type.value == "agy" and p.is_active), None - ) + provider = next((p for p in providers if p.type.value == "agy" and p.is_active), None) if provider is None: return [] - return await ModelCredentialRepository(session).list_active_for_provider( - provider.id - ) + return await ModelCredentialRepository(session).list_active_for_provider(provider.id) async def report_agy_state( diff --git a/apps/api/tests/test_agy.py b/apps/api/tests/test_agy.py index 7137adf..f2c0773 100644 --- a/apps/api/tests/test_agy.py +++ b/apps/api/tests/test_agy.py @@ -27,9 +27,7 @@ from tests.conftest import auth_header, signup # --------------------------------------------------------------------------- GOOD_GENERATE: dict = { - "candidates": [ - {"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"} - ], + "candidates": [{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}], "usageMetadata": { "promptTokenCount": 7, "candidatesTokenCount": 3, @@ -192,9 +190,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token): headers=auth_header(platform_owner_token), json={ "login_id": login_id, - "callback_url": ( - f"http://localhost:51121/oauth-callback?code=auth-code&state={state}" - ), + "callback_url": (f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"), }, ) assert completed.status_code == 201, completed.text @@ -209,9 +205,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token): assert "agy-proj-123" in completed.text # project id is admin metadata # 3. the account is listed. - listed = await client.get( - "/api/admin/agy/accounts", headers=auth_header(platform_owner_token) - ) + listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token)) assert listed.status_code == 200 emails = [a["email"] for a in listed.json()] assert emails == ["person@gmail.com"] @@ -232,9 +226,7 @@ async def test_agy_complete_login_rejects_unrelated_url(client, google, platform ) assert completed.status_code == 422 # The failed attempt must not have created an account. - listed = await client.get( - "/api/admin/agy/accounts", headers=auth_header(platform_owner_token) - ) + listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token)) assert listed.json() == [] @@ -249,9 +241,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token) headers=auth_header(platform_owner_token), json={ "login_id": started.json()["login_id"], - "callback_url": ( - f"http://localhost:51121/oauth-callback?code=c&state={state}" - ), + "callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"), }, ) account_id = created.json()["id"] @@ -270,9 +260,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token) headers=auth_header(platform_owner_token), ) assert deleted.status_code == 204 - listed = await client.get( - "/api/admin/agy/accounts", headers=auth_header(platform_owner_token) - ) + listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token)) assert listed.json() == [] @@ -289,15 +277,11 @@ async def test_agy_same_account_twice_replaces(client, google, platform_owner_to headers=auth_header(platform_owner_token), json={ "login_id": started.json()["login_id"], - "callback_url": ( - f"http://localhost:51121/oauth-callback?code=c&state={state}" - ), + "callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"), }, ) assert done.status_code == 201 - listed = await client.get( - "/api/admin/agy/accounts", headers=auth_header(platform_owner_token) - ) + listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token)) assert len(listed.json()) == 1 @@ -390,9 +374,7 @@ async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_f def test_auth_url_shape(): challenge = agy.AgyAuthClient().begin() query = parse_qs(urlparse(challenge.auth_url).query) - assert challenge.auth_url.startswith( - "https://accounts.google.com/o/oauth2/v2/auth" - ) + assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth") assert query["client_id"][0] == ( "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" ) @@ -410,9 +392,7 @@ async def test_paste_flow_exchanges_and_resolves_account(google): """start → paste callback URL → account material, verifier stays server-side.""" login_id, _auth_url = await agy.begin_login() challenge = agy._CHALLENGES[login_id] - callback = ( - f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}" - ) + callback = f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}" result = await agy.redeem_login(login_id, callback) assert result.email == "person@gmail.com" assert result.project_id == "agy-proj-123" @@ -446,11 +426,10 @@ async def test_google_error_page_is_rejected(google): async def test_refresh_token_missing_is_actionable(monkeypatch): """A token response without ``refresh_token`` fails loudly and tells why.""" + async def handler(method, url, json_body, headers, data): if url.endswith("/token"): - return _FakeResponse( - json_data={"access_token": "a", "expires_in": 3600} - ) + return _FakeResponse(json_data={"access_token": "a", "expires_in": 3600}) return await default_handler(method, url, json_body, headers, data) patch_google(monkeypatch, handler) @@ -617,7 +596,8 @@ def test_response_parsing_skips_thoughts(): async def test_complete_happy_path(google, decrypted): adapter = agy.AgyModelAdapter() response = await adapter.complete( - model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")], + model=_MODEL, + credentials=[_agy_credential(email="one@gmail.com")], messages=_MESSAGES, ) assert response.content == "سلام" @@ -631,6 +611,7 @@ async def test_complete_happy_path(google, decrypted): async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted): """A 429 on one account must not fail the request: the pool moves on.""" + async def handler(method, url, json_body, headers, data): if url.endswith("/token"): # Each account's refresh token maps to its own access token. @@ -642,9 +623,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte auth = headers.get("Authorization", "") if auth.endswith("access-one"): # Both base URLs hit the same quota. - return _FakeResponse( - status_code=429, text="quota", headers={"Retry-After": "120"} - ) + return _FakeResponse(status_code=429, text="quota", headers={"Retry-After": "120"}) return _FakeResponse(json_data=GOOD_GENERATE) return await default_handler(method, url, json_body, headers, data) @@ -659,9 +638,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte adapter._state.cursor = -1 one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1") two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2") - response = await adapter.complete( - model=_MODEL, credentials=[one, two], messages=_MESSAGES - ) + response = await adapter.complete(model=_MODEL, credentials=[one, two], messages=_MESSAGES) assert response.content == "سلام" cooldowns = [r for r in reported if r[1] == "cooldown"] @@ -696,6 +673,7 @@ async def test_every_account_rate_limited_raises(monkeypatch, decrypted): async def test_rejected_grant_marks_account_error(monkeypatch, decrypted): """401 means the OAuth grant died — bench as error, move to next account.""" + async def handler(method, url, json_body, headers, data): if url.endswith("/token"): access = {"refresh-1": "access-one", "refresh-2": "access-two"}[ @@ -719,9 +697,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted): adapter._state.cursor = -1 dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1") alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2") - response = await adapter.complete( - model=_MODEL, credentials=[dead, alive], messages=_MESSAGES - ) + response = await adapter.complete(model=_MODEL, credentials=[dead, alive], messages=_MESSAGES) assert response.content == "سلام" errors = [r for r in reported if r[1] == "error"] assert errors and errors[0][0] == dead.id @@ -731,6 +707,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted): async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted): """5xx on one backend URL is not the account's fault — try the other.""" + async def handler(method, url, json_body, headers, data): if url.endswith("/token"): return _token_response() @@ -743,7 +720,8 @@ async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted patch_google(monkeypatch, handler) adapter = agy.AgyModelAdapter() response = await adapter.complete( - model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")], + model=_MODEL, + credentials=[_agy_credential(email="one@gmail.com")], messages=_MESSAGES, ) assert response.content == "سلام" @@ -782,9 +760,7 @@ async def test_available_models(google, decrypted): async def test_available_models_for_one_account(google, decrypted): """The console asks for a specific account, so the fetch is per-account.""" adapter = agy.AgyModelAdapter() - models = await adapter.available_models_for( - _agy_credential(email="one@gmail.com") - ) + models = await adapter.available_models_for(_agy_credential(email="one@gmail.com")) assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"] assert models[1].supported_efforts == ["low", "medium", "high"] # The list is fetched from Google every call — no cached catalog. @@ -818,9 +794,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token): headers=auth_header(platform_owner_token), json={ "login_id": started.json()["login_id"], - "callback_url": ( - f"http://localhost:51121/oauth-callback?code=c&state={state}" - ), + "callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"), }, ) ).json()["id"] @@ -843,6 +817,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token): async def test_completion_carries_the_model_effort(monkeypatch, decrypted): """The end-to-end path: effort on the model row reaches the payload.""" + async def handler(method, url, json_body, headers, data): if url.endswith("/token"): return _token_response() @@ -868,3 +843,104 @@ async def test_completion_carries_the_model_effort(monkeypatch, decrypted): "thinkingBudget": 4096, "includeThoughts": True, } + + +def test_parse_cli_models_output(): + """Verify parsing raw output from agy models CLI.""" + sample_output = ( + "Fetching available models...\n" + "gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n" + "gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\n" + "gemini-3.8-flash-low\tGemini 3.8 Flash (Low)\n" + "claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n" + "claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n" + "gpt-oss-120b-medium\tGPT-OSS 120B (Medium)\n" + ) + models = agy.AgyModelAdapter._parse_cli_models_output(sample_output) + assert len(models) == 6 + + m_high = next(m for m in models if m.id == "gemini-3.8-flash-high") + assert m_high.display_name == "Gemini 3.8 Flash (High)" + assert m_high.supports_thinking is True + assert m_high.supported_efforts == ["high"] + assert m_high.default_effort == "high" + + m_med = next(m for m in models if m.id == "gemini-3.8-flash-medium") + assert m_med.supported_efforts == ["medium"] + assert m_med.default_effort == "medium" + + m_low = next(m for m in models if m.id == "gemini-3.8-flash-low") + assert m_low.supported_efforts == ["low"] + assert m_low.default_effort == "low" + + m_claude = next(m for m in models if m.id == "claude-sonnet-4-6") + assert m_claude.supports_thinking is True + + m_gpt = next(m for m in models if m.id == "gpt-oss-120b-medium") + assert m_gpt.supported_efforts == ["medium"] + + +def test_build_generate_payload_tiered_mapping(): + """Verify virtual CLI models map to underlying tiered model with appropriate budget.""" + account = agy.AgyAccount( + credential_id="cred-1", + refresh_token="rt-1", + email="test@example.com", + project_id="proj-123", + ) + messages = [ChatMessage(role="user", content="Hello")] + + payload_low = agy._build_generate_payload( + account, + "gemini-3.8-flash-low", + messages, + system="", + temperature=0.7, + max_tokens=4096, + ) + assert payload_low["model"] == "gemini-3.8-flash-tiered" + assert payload_low["request"]["generationConfig"]["thinkingConfig"] == { + "thinkingBudget": 4096, + "includeThoughts": True, + } + + payload_high = agy._build_generate_payload( + account, + "gemini-3.8-flash-high", + messages, + system="", + temperature=0.7, + max_tokens=4096, + ) + assert payload_high["model"] == "gemini-3.8-flash-tiered" + assert payload_high["request"]["generationConfig"]["thinkingConfig"] == { + "thinkingBudget": 16384, + "includeThoughts": True, + } + + +async def test_fetch_models_via_cli_mocked(monkeypatch): + """Verify _fetch_models_via_cli invokes the CLI binary and handles output.""" + account = agy.AgyAccount( + credential_id="cred-1", + refresh_token="rt-test-123", + email="test@example.com", + project_id="proj-123", + ) + + class FakeProc: + returncode = 0 + + async def communicate(self): + return b"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n", b"" + + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProc() + + monkeypatch.setattr(agy.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(agy.shutil, "which", lambda cmd: "/usr/bin/agy") + + models = await agy.AgyModelAdapter._fetch_models_via_cli(account) + assert len(models) == 1 + assert models[0].id == "gemini-3.8-flash-high" + assert models[0].display_name == "Gemini 3.8 Flash (High)" diff --git a/apps/web/package.json b/apps/web/package.json index 55ff900..73ab2a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@agy/web", - "version": "0.1.3", + "version": "0.1.4", "private": true, "description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.", "scripts": { diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts index 7481377..4b59408 100644 --- a/apps/web/src/lib/version.ts +++ b/apps/web/src/lib/version.ts @@ -12,7 +12,7 @@ * "the app is on the wrong version" bug into a failing test, which is the * whole point of keeping them separate. */ -export const APP_VERSION = "0.1.3"; +export const APP_VERSION = "0.1.4"; /** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */ export const APP_VERSION_LABEL = `v${APP_VERSION}`; diff --git a/package.json b/package.json index fb23386..1fed07d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smart-org", - "version": "0.1.3", + "version": "0.1.4", "private": true, "description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.", "packageManager": "pnpm@9.12.0", From f1e5df29ce669568c1913dd10b8111699d66c5cf Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 11:41:01 +0330 Subject: [PATCH 05/12] feat: realtime WebSocket layer for live events and streaming logs - Backend: ConnectionManager, Redis-backed bus with in-process fallback, channel authorization, and typed emit helpers wired into workers (ai_runtime, sandbox_gateway), workforce/sandbox/billing/audit/error services. - Frontend: single shared WS client with backoff + heartbeat, RealtimeProvider, useLiveChannel/useLiveEvent hooks, live indicators on task detail, manager chat, dashboard, traces, sandbox terminal, admin errors, and billing. - Contracts: WsEvent/WsServerFrame/WsClientFrame types mirroring the port. All 255 backend tests pass; web typecheck and build pass. --- apps/api/app/application/ports/realtime.py | 504 ++++++++++++++++++ .../app/application/services/audit_service.py | 12 +- .../application/services/billing_service.py | 13 +- .../application/services/sandbox_service.py | 29 + .../application/services/workforce_service.py | 223 +++++++- apps/api/app/core/config.py | 9 + apps/api/app/infrastructure/error_log.py | 13 + .../api/app/interfaces/http/routers/health.py | 17 + apps/api/app/interfaces/realtime/__init__.py | 20 + apps/api/app/interfaces/realtime/authz.py | 112 ++++ apps/api/app/interfaces/realtime/bus.py | 260 +++++++++ apps/api/app/interfaces/realtime/manager.py | 217 ++++++++ apps/api/app/interfaces/realtime/router.py | 175 ++++++ apps/api/app/main.py | 17 + apps/api/app/workers/__main__.py | 5 + apps/api/app/workers/ai_runtime.py | 8 + apps/api/app/workers/sandbox_gateway.py | 8 + apps/api/tests/conftest.py | 12 + apps/api/tests/test_realtime.py | 416 +++++++++++++++ apps/web/src/app/(app)/admin/errors/page.tsx | 58 ++ apps/web/src/app/(app)/billing/page.tsx | 39 ++ apps/web/src/app/(app)/dashboard/page.tsx | 27 + apps/web/src/app/(app)/manager/page.tsx | 60 ++- apps/web/src/app/(app)/tasks/[id]/page.tsx | 109 +++- apps/web/src/app/(app)/traces/page.tsx | 68 ++- apps/web/src/app/layout.tsx | 6 +- apps/web/src/components/AppShell.tsx | 36 ++ apps/web/src/components/sandbox-panel.tsx | 48 ++ apps/web/src/lib/realtime.tsx | 135 +++++ apps/web/src/lib/ws.ts | 270 ++++++++++ packages/contracts/src/index.ts | 83 +++ 31 files changed, 2981 insertions(+), 28 deletions(-) create mode 100644 apps/api/app/application/ports/realtime.py create mode 100644 apps/api/app/interfaces/realtime/__init__.py create mode 100644 apps/api/app/interfaces/realtime/authz.py create mode 100644 apps/api/app/interfaces/realtime/bus.py create mode 100644 apps/api/app/interfaces/realtime/manager.py create mode 100644 apps/api/app/interfaces/realtime/router.py create mode 100644 apps/api/tests/test_realtime.py create mode 100644 apps/web/src/lib/realtime.tsx create mode 100644 apps/web/src/lib/ws.ts diff --git a/apps/api/app/application/ports/realtime.py b/apps/api/app/application/ports/realtime.py new file mode 100644 index 0000000..49513a1 --- /dev/null +++ b/apps/api/app/application/ports/realtime.py @@ -0,0 +1,504 @@ +"""Realtime push contract: the event envelope and the typed publishers. + +This module is an **application port**. Services depend on it; the WebSocket +adapter in ``app.interfaces.realtime`` implements it. That direction matters: +the run engine must be able to publish without knowing that a browser exists, +and a worker process that never serves a socket still publishes the same way. + +Two parts: + +* **The envelope** (:class:`RealtimeEvent`) and the channel naming scheme. + Framework-free, like every other port. +* **The typed publishers** — ``emit_run_step`` and friends. They own the + fan-out channels and the one rule that makes live feeds safe to call from + anywhere: *publishing never raises*. A feed must not be able to fail a task + run, a sandbox command or a billing write. + +The publisher is process-wide state, set once by a composition root +(``app.main`` for the API, ``app.workers.__main__`` for the workers). Until +then it is a :class:`NullPublisher` — publishing is a no-op rather than a +missing-import crash. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol, runtime_checkable + +from app.domain.base import new_id, utcnow + +logger = logging.getLogger("agy.realtime") + +#: The platform-owner console stream. Authorized against ``is_platform_owner``. +ADMIN_CHANNEL = "admin" + + +# --------------------------------------------------------------------------- +# Channels +# --------------------------------------------------------------------------- + + +def org_channel(organization_id: str) -> str: + """Everything happening inside one tenant.""" + return f"org:{organization_id}" + + +def task_channel(task_id: str) -> str: + return f"task:{task_id}" + + +def run_channel(run_id: str) -> str: + return f"run:{run_id}" + + +def thread_channel(thread_id: str) -> str: + return f"thread:{thread_id}" + + +def session_channel(session_id: str) -> str: + """One sandbox session — the live command terminal subscribes here.""" + return f"session:{session_id}" + + +def user_channel(user_id: str) -> str: + return f"user:{user_id}" + + +def admin_channel() -> str: + return ADMIN_CHANNEL + + +def dedupe_channels(channels: Iterable[str]) -> tuple[str, ...]: + """Preserve order, drop duplicates — a publisher may overlap channels.""" + seen: dict[str, None] = {} + for channel in channels: + if channel and channel not in seen: + seen[channel] = None + return tuple(seen) + + +def with_org_channel(channels: Sequence[str], organization_id: str | None) -> tuple[str, ...]: + """Append the tenant channel unless the caller already listed it.""" + if not organization_id: + return dedupe_channels(channels) + return dedupe_channels([*channels, org_channel(organization_id)]) + + +# --------------------------------------------------------------------------- +# Envelope +# --------------------------------------------------------------------------- + + +@dataclass(slots=True, kw_only=True) +class RealtimeEvent: + """One push to one or more channels. + + ``channels`` is the fan-out list — the same event typically goes to the + narrow channel (``run:…``) and the wide one (``org:…``), so a page watching + a single run and a dashboard watching the whole tenant both stay live + without the publisher caring who is listening. + """ + + type: str + channels: tuple[str, ...] + data: dict = field(default_factory=dict) + organization_id: str | None = None + id: str = field(default_factory=new_id) + at: datetime = field(default_factory=utcnow) + + def envelope(self) -> dict: + """The JSON object a browser receives. + + ``at`` is serialised as an ISO-8601 UTC string: the client renders it + with ``toLocaleString`` like every other timestamp in the product. + """ + return { + "id": self.id, + "type": self.type, + "data": self.data, + "organization_id": self.organization_id, + "at": self.at.isoformat(), + } + + def to_json(self) -> str: + return json.dumps(self.envelope(), ensure_ascii=False, default=str) + + @classmethod + def from_json(cls, raw: str | bytes) -> RealtimeEvent: + payload = json.loads(raw) + return cls( + type=str(payload["type"]), + channels=tuple(payload.get("channels") or ()), + data=dict(payload.get("data") or {}), + organization_id=payload.get("organization_id"), + id=str(payload["id"]), + at=_parse_at(payload.get("at")), + ) + + +def _parse_at(value: object) -> datetime: + if isinstance(value, str) and value: + try: + return datetime.fromisoformat(value) + except ValueError: + return utcnow() + return utcnow() + + +# --------------------------------------------------------------------------- +# Publisher plumbing +# --------------------------------------------------------------------------- + + +@runtime_checkable +class EventPublisher(Protocol): + async def publish(self, event: RealtimeEvent) -> None: ... + + +class NullPublisher: + """Default before a composition root wires the real bus.""" + + async def publish(self, event: RealtimeEvent) -> None: + return None + + +_PUBLISHER: EventPublisher = NullPublisher() + + +def set_publisher(publisher: EventPublisher) -> None: + """Install the process-wide publisher. Called by composition roots only.""" + global _PUBLISHER + _PUBLISHER = publisher + + +def get_publisher() -> EventPublisher: + return _PUBLISHER + + +async def publish(event: RealtimeEvent) -> None: + """Publish through the installed publisher. Never raises.""" + try: + await _PUBLISHER.publish(event) + except Exception: # noqa: BLE001 - the feed must never fail a caller + logger.exception("realtime: publish failed for %s", event.type) + + +async def _emit( + *, + type: str, + channels: tuple[str, ...], + data: dict, + organization_id: str | None = None, +) -> None: + await publish( + RealtimeEvent( + type=type, + channels=with_org_channel(channels, organization_id), + data=data, + organization_id=organization_id, + ) + ) + + +# --------------------------------------------------------------------------- +# Typed publishers +# +# Payload shape is the product contract with the browser: the client-side types +# in ``packages/contracts`` mirror ``data`` key-for-key. Keep the two in sync. +# --------------------------------------------------------------------------- + +#: Long text fields are clipped in the live feed. The full record stays +#: available through the REST endpoints — the feed is a signal, not an archive. +MAX_LIVE_TEXT_CHARS = 8000 + + +def _clip(text: str) -> str: + return text[:MAX_LIVE_TEXT_CHARS] + + +async def emit_run_step( + *, + run_id: str, + task_id: str, + organization_id: str, + step_id: str, + index: int, + kind: str, + title: str, + payload: dict | None = None, +) -> None: + await _emit( + type="run.step", + channels=(run_channel(run_id), task_channel(task_id)), + organization_id=organization_id, + data={ + "run_id": run_id, + "task_id": task_id, + "step": { + "id": step_id, + "index": index, + "kind": kind, + "title": title, + "payload": payload or {}, + }, + }, + ) + + +async def emit_run_status( + *, + run_id: str, + task_id: str, + organization_id: str, + status: str, + error: str | None = None, +) -> None: + await _emit( + type="run.status", + channels=(run_channel(run_id), task_channel(task_id)), + organization_id=organization_id, + data={"run_id": run_id, "task_id": task_id, "status": status, "error": error}, + ) + + +async def emit_task_status(*, task_id: str, organization_id: str, status: str) -> None: + await _emit( + type="task.status", + channels=(task_channel(task_id),), + organization_id=organization_id, + data={"task_id": task_id, "status": status}, + ) + + +async def emit_task_created( + *, task_id: str, organization_id: str, title: str, status: str +) -> None: + await _emit( + type="task.created", + channels=(task_channel(task_id),), + organization_id=organization_id, + data={"task_id": task_id, "title": title, "status": status}, + ) + + +async def emit_chat_pending(*, thread_id: str, organization_id: str) -> None: + """The manager is thinking — the client shows a typing indicator.""" + await _emit( + type="chat.pending", + channels=(thread_channel(thread_id),), + organization_id=organization_id, + data={"thread_id": thread_id}, + ) + + +async def emit_message_created( + *, + thread_id: str, + organization_id: str, + message_id: str, + role: str, + content: str, + employee_id: str | None = None, +) -> None: + await _emit( + type="message.created", + channels=(thread_channel(thread_id),), + organization_id=organization_id, + data={ + "thread_id": thread_id, + "message": { + "id": message_id, + "role": role, + "content": _clip(content), + "employee_id": employee_id, + }, + }, + ) + + +async def emit_approval_pending( + *, + run_id: str, + task_id: str, + organization_id: str, + tool_call_id: str, + tool_key: str, + requires_approval: bool, +) -> None: + await _emit( + type="approval.pending", + channels=(run_channel(run_id), task_channel(task_id)), + organization_id=organization_id, + data={ + "run_id": run_id, + "task_id": task_id, + "tool_call_id": tool_call_id, + "tool_key": tool_key, + "requires_approval": requires_approval, + }, + ) + + +async def emit_artifact_created( + *, run_id: str, task_id: str, organization_id: str, artifact_id: str, name: str +) -> None: + await _emit( + type="artifact.created", + channels=(run_channel(run_id), task_channel(task_id)), + organization_id=organization_id, + data={ + "run_id": run_id, + "task_id": task_id, + "artifact_id": artifact_id, + "name": name, + }, + ) + + +async def emit_sandbox_command( + *, + session_id: str, + organization_id: str, + command_run_id: str, + command: str, + exit_code: int | None, + stdout: str, + stderr: str, + duration_ms: int, + truncated: bool, + status: str = "finished", +) -> None: + """One command's output, pushed live to the session's terminal panel.""" + await _emit( + type="sandbox.command", + channels=(session_channel(session_id),), + organization_id=organization_id, + data={ + "session_id": session_id, + "run_id": command_run_id, + "command": _clip(command), + "status": status, + "exit_code": exit_code, + "stdout": _clip(stdout), + "stderr": _clip(stderr), + "duration_ms": duration_ms, + "truncated": truncated, + }, + ) + + +async def emit_sandbox_session( + *, + session_id: str, + organization_id: str, + status: str, + workspace_id: str | None = None, +) -> None: + await _emit( + type="sandbox.session", + channels=(session_channel(session_id),), + organization_id=organization_id, + data={ + "session_id": session_id, + "status": status, + "workspace_id": workspace_id, + }, + ) + + +async def emit_sandbox_reaped( + *, organization_id: str | None, session_id: str, workspace_id: str +) -> None: + await _emit( + type="sandbox.reaped", + channels=(session_channel(session_id),), + organization_id=organization_id, + data={"session_id": session_id, "workspace_id": workspace_id}, + ) + + +async def emit_usage_recorded( + *, + organization_id: str, + unit: str, + quantity: float, + run_id: str | None = None, + employee_id: str | None = None, +) -> None: + await _emit( + type="usage.recorded", + channels=(), + organization_id=organization_id, + data={ + "unit": unit, + "quantity": quantity, + "run_id": run_id, + "employee_id": employee_id, + }, + ) + + +async def emit_wallet_changed(*, organization_id: str, balance: float) -> None: + await _emit( + type="billing.wallet", + channels=(), + organization_id=organization_id, + data={"organization_id": organization_id, "balance": balance}, + ) + + +async def emit_error_recorded( + *, + error_id: str | None, + kind: str, + message: str, + severity: str, + source: str, + organization_id: str | None = None, +) -> None: + """Feed the platform-owner console; tenants see their own through the org + channel the envelope carries.""" + await _emit( + type="error.recorded", + channels=("admin",), + organization_id=organization_id, + data={ + "error_id": error_id, + "kind": kind, + "message": _clip(message), + "severity": severity, + "source": source, + "organization_id": organization_id, + }, + ) + + +async def emit_audit_recorded( + *, organization_id: str | None, action: str, target_type: str, target_id: str +) -> None: + if not organization_id: + return + await _emit( + type="audit.recorded", + channels=(), + organization_id=organization_id, + data={"action": action, "target_type": target_type, "target_id": target_id}, + ) + + +async def emit_worker_status( + *, + organization_id: str, + worker: str, + message: str, +) -> None: + await _emit( + type="worker.status", + channels=(), + organization_id=organization_id, + data={"worker": worker, "message": _clip(message)}, + ) diff --git a/apps/api/app/application/services/audit_service.py b/apps/api/app/application/services/audit_service.py index 40ebff4..77af591 100644 --- a/apps/api/app/application/services/audit_service.py +++ b/apps/api/app/application/services/audit_service.py @@ -8,6 +8,7 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession +from app.application.ports.realtime import emit_audit_recorded from app.domain.audit.models import AuditEvent from app.infrastructure.repositories.audit import AuditRepository @@ -34,4 +35,13 @@ async def record_audit( ip_address=ip, user_agent=user_agent, ) - return await AuditRepository(session).record(event=event) + recorded = await AuditRepository(session).record(event=event) + # Live push: the audit timelines (dashboard, traces) update as actions + # happen instead of waiting for the next reload. + await emit_audit_recorded( + organization_id=organization_id, + action=action, + target_type=target_type, + target_id=target_id, + ) + return recorded diff --git a/apps/api/app/application/services/billing_service.py b/apps/api/app/application/services/billing_service.py index 6700eb7..b9a9e21 100644 --- a/apps/api/app/application/services/billing_service.py +++ b/apps/api/app/application/services/billing_service.py @@ -14,6 +14,7 @@ from __future__ import annotations from datetime import datetime from app.application.ports import PaymentProviderPort +from app.application.ports.realtime import emit_wallet_changed from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError from app.domain.billing.models import ( CreditWallet, @@ -121,6 +122,11 @@ class BillingService: # Refuse to leave a wallet in debt; undo and surface the conflict. await self.wallets.adjust(organization_id=organization_id, delta=-amount) raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.") + # Live push: the sidebar credit badge and the billing page follow the + # wallet as it moves, whoever moved it. + await emit_wallet_changed( + organization_id=organization_id, balance=wallet.balance + ) return wallet async def start_checkout( @@ -177,8 +183,13 @@ class BillingService: return payment if succeeded: - await self.wallets.adjust(organization_id=organization_id, delta=payment.amount) + wallet = await self.wallets.adjust( + organization_id=organization_id, delta=payment.amount + ) final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED) + await emit_wallet_changed( + organization_id=organization_id, balance=wallet.balance + ) else: final = await self.payments.set_status(payment_id, PaymentStatus.FAILED) return final or payment diff --git a/apps/api/app/application/services/sandbox_service.py b/apps/api/app/application/services/sandbox_service.py index 8e42185..dd3f41e 100644 --- a/apps/api/app/application/services/sandbox_service.py +++ b/apps/api/app/application/services/sandbox_service.py @@ -18,6 +18,10 @@ import contextlib from datetime import datetime from app.application.ports import CommandResult, SandboxProviderPort, UsageRecord +from app.application.ports.realtime import ( + emit_sandbox_command, + emit_sandbox_session, +) from app.core.config import settings from app.core.errors import ConflictError, NotFoundError, ProviderUnavailableError, ValidationError from app.domain.ai_control.models import RateUnit @@ -210,6 +214,12 @@ class SandboxService: ) ) await self.workspaces.set_status(workspace.id, WorkspaceStatus.READY) + await emit_sandbox_session( + session_id=session.id, + organization_id=organization_id, + status=SessionStatus.RUNNING.value, + workspace_id=workspace.id, + ) return session async def get_session_or_404( @@ -229,6 +239,11 @@ class SandboxService: 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) + await emit_sandbox_session( + session_id=session.id, + organization_id=organization_id, + status=SessionStatus.STOPPED.value, + ) return refreshed or session # -- commands ------------------------------------------------------------ @@ -285,6 +300,20 @@ class SandboxService: ) await self.sessions.set_status(session.id, SessionStatus.RUNNING) + # Live push: the terminal panel on the project page streams the command + # and its output as the row is written, not on the next poll. + await emit_sandbox_command( + session_id=session.id, + organization_id=organization_id, + command_run_id=run.id, + command=cleaned, + exit_code=result.exit_code, + stdout=run.stdout_ref, + stderr=run.stderr_ref, + duration_ms=result.duration_ms, + truncated=result.truncated, + ) + # 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: diff --git a/apps/api/app/application/services/workforce_service.py b/apps/api/app/application/services/workforce_service.py index e30ff7d..613025c 100644 --- a/apps/api/app/application/services/workforce_service.py +++ b/apps/api/app/application/services/workforce_service.py @@ -24,6 +24,17 @@ from app.application.ports import ( ModelProviderPort, UsageRecord, ) +from app.application.ports.realtime import ( + emit_approval_pending, + emit_artifact_created, + emit_chat_pending, + emit_message_created, + emit_run_status, + emit_run_step, + emit_task_created, + emit_task_status, + emit_usage_recorded, +) from app.core.errors import ( ConflictError, NotFoundError, @@ -324,6 +335,10 @@ class WorkforceService: else: employee = await self._default_manager(organization_id) + # The manager is about to call a model. Tell the thread so the UI can + # show a thinking indicator immediately. + await emit_chat_pending(thread_id=thread.id, organization_id=organization_id) + history = await self.messages.list_for_thread(thread.id) system_prompt = await self._system_prompt_for(employee) reply = await self._complete( @@ -347,6 +362,16 @@ class WorkforceService: employee_id=employee.id if employee else None, ) ) + # Live push: any browser on this thread sees the reply arrive, and the + # pending indicator clears, without waiting for a reload. + await emit_message_created( + thread_id=thread.id, + organization_id=organization_id, + message_id=assistant_message.id, + role=MessageRole.ASSISTANT.value, + content=reply, + employee_id=employee.id if employee else None, + ) return user_message, assistant_message # ------------------------------------------------------------------ # @@ -371,7 +396,7 @@ class WorkforceService: await self.get_employee_or_404( organization_id=organization_id, employee_id=assigned_employee_id ) - return await self.tasks.create( + task = await self.tasks.create( Task( organization_id=organization_id, title=title.strip(), @@ -382,6 +407,15 @@ class WorkforceService: created_by_user_id=created_by_user_id, ) ) + # Live push: the tasks list and the manager-chat sidebar update the + # moment the task exists, whoever created it and from where. + await emit_task_created( + task_id=task.id, + organization_id=organization_id, + title=task.title, + status=task.status.value, + ) + return task async def list_tasks( self, @@ -435,21 +469,39 @@ class WorkforceService: Run(task_id=task.id, employee_id=employee.id if employee else None) ) await self.tasks.set_status(task.id, TaskStatus.RUNNING) + await emit_task_status( + task_id=task.id, organization_id=organization_id, status=TaskStatus.RUNNING.value + ) - await self.run_steps.append( - run_id=run.id, kind="queued", title="کار در صف اجرا قرار گرفت.", - payload={"task_id": task.id}, + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=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="برنامه‌ریزی مرحلهٔ اجرا." + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=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="اجرای کار آغاز شد." + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=await self.run_steps.append( + run_id=run.id, kind="running", title="اجرای کار آغاز شد." + ), ) reply = await self._complete( @@ -463,9 +515,14 @@ class WorkforceService: ChatMessage(role="user", content=self._task_brief(task)), ], ) - await self.run_steps.append( - run_id=run.id, kind="output", title="خروجی آماده شد.", - payload={"chars": len(reply)}, + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=await self.run_steps.append( + run_id=run.id, kind="output", title="خروجی آماده شد.", + payload={"chars": len(reply)}, + ), ) artifact = await self.artifacts.create( @@ -479,30 +536,106 @@ class WorkforceService: size_bytes=len(reply.encode("utf-8")), ) ) - await self.run_steps.append( - run_id=run.id, kind="artifact", title="سند خروجی ساخته شد.", - payload={"artifact_id": artifact.id}, + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=await self.run_steps.append( + run_id=run.id, kind="artifact", title="سند خروجی ساخته شد.", + payload={"artifact_id": artifact.id}, + ), + ) + await emit_artifact_created( + run_id=run.id, + task_id=task.id, + organization_id=organization_id, + artifact_id=artifact.id, + name=artifact.name, ) run = await self._finish(run, RunStatus.COMPLETED) await self.tasks.set_status(task.id, TaskStatus.DONE) + await emit_run_status( + run_id=run.id, + task_id=task.id, + organization_id=organization_id, + status=RunStatus.COMPLETED.value, + ) + await emit_task_status( + task_id=task.id, organization_id=organization_id, status=TaskStatus.DONE.value + ) 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)}, + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=await self.run_steps.append( + run_id=run.id, kind="error", title="اجرا ناموفق بود.", + payload={"error": str(exc)}, + ), + ) + await emit_run_status( + run_id=run.id, + task_id=task.id, + organization_id=organization_id, + status=RunStatus.FAILED.value, + error=str(exc), + ) + await emit_task_status( + task_id=task.id, organization_id=organization_id, status=TaskStatus.FAILED.value ) 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)}, + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=task.id, + step=await self.run_steps.append( + run_id=run.id, kind="error", title="خطای غیرمنتظره در اجرا.", + payload={"error": str(exc)}, + ), + ) + await emit_run_status( + run_id=run.id, + task_id=task.id, + organization_id=organization_id, + status=RunStatus.FAILED.value, + error=str(exc), + ) + await emit_task_status( + task_id=task.id, organization_id=organization_id, status=TaskStatus.FAILED.value ) return run + async def _publish_step( + self, + *, + organization_id: str, + run_id: str, + task_id: str, + step: RunStep, + ) -> None: + """Push one timeline step to the run, the task and the tenant feed. + + The step is already persisted by the repository; this only broadcasts + it. Kept as a helper so the run engine above reads as the plain + sequence of transitions it is. + """ + await emit_run_step( + run_id=run_id, + task_id=task_id, + organization_id=organization_id, + step_id=step.id, + index=step.index, + kind=step.kind, + title=step.title, + payload=step.payload_json, + ) + async def get_run_or_404( self, *, organization_id: str, run_id: str ) -> tuple[Run, list[RunStep], list[ToolCall], list[Artifact]]: @@ -530,12 +663,28 @@ class WorkforceService: raise ConflictError("از وضعیت فعلی امکان لغو وجود ندارد.") run = await self._finish(run, RunStatus.CANCELLED) - await self.run_steps.append( - run_id=run.id, kind="cancelled", title="اجرا توسط کاربر لغو شد." + await self._publish_step( + organization_id=organization_id, + run_id=run.id, + task_id=run.task_id, + step=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) + await emit_task_status( + task_id=task.id, + organization_id=organization_id, + status=TaskStatus.CANCELLED.value, + ) + await emit_run_status( + run_id=run.id, + task_id=run.task_id, + organization_id=organization_id, + status=RunStatus.CANCELLED.value, + ) return run async def list_pending_approvals(self, organization_id: str) -> list[ToolCall]: @@ -627,6 +776,14 @@ class WorkforceService: ), payload={"tool_call_id": call.id, "requires_approval": needs_approval}, ) + await emit_approval_pending( + run_id=run.id, + task_id=run.task_id, + organization_id=organization_id, + tool_call_id=call.id, + tool_key=tool_key, + requires_approval=needs_approval, + ) return call async def decide_approval( @@ -655,12 +812,25 @@ class WorkforceService: result=result, approved_by_user_id=decided_by_user_id, ) + run = await self.runs.get(call.run_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}, ) + # Live push: the task page clears the pending card and the run timeline + # gains the decision without a reload. + await emit_run_step( + run_id=call.run_id, + task_id=run.task_id if run else "", + organization_id=organization_id, + step_id=call.id, + index=0, + kind="approval", + title=("درخواست تأیید شد." if approve else "درخواست رد شد."), + payload={"tool_call_id": call.id, "tool_key": call.tool_key}, + ) return updated or call # ------------------------------------------------------------------ # @@ -776,6 +946,15 @@ class WorkforceService: metadata_json={"priced": card is not None}, ) ) + # Live push: the billing page and the sidebar credit badge react to + # consumption as it happens instead of at the next page load. + await emit_usage_recorded( + organization_id=organization_id, + unit=unit.value if hasattr(unit, "value") else str(unit), + quantity=quantity, + run_id=run_id, + employee_id=employee_id, + ) async def _advance(self, run: Run, target: RunStatus) -> Run: if not can_transition_run(run.status, target): diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index b41b612..5de0487 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -53,6 +53,15 @@ class Settings(BaseSettings): database_url: str = "sqlite+aiosqlite:///./agy_web.db" redis_url: str = "redis://localhost:6379/0" + # --- realtime push ------------------------------------------------------- + #: How live events travel between processes. ``auto`` mirrors published + #: events onto Redis pub/sub so the standalone workers' pushes reach the + #: API process's browser sockets; if no broker is reachable the bus falls + #: back to in-process fan-out and every API-driven feed stays live. + #: ``local`` pins the bus to in-process delivery (tests, single-process + #: deployments without a broker). + realtime_transport: str = "auto" + # --- security ------------------------------------------------------------ jwt_secret: str = "change-me" jwt_algorithm: str = "HS256" diff --git a/apps/api/app/infrastructure/error_log.py b/apps/api/app/infrastructure/error_log.py index 9505c9a..423b802 100644 --- a/apps/api/app/infrastructure/error_log.py +++ b/apps/api/app/infrastructure/error_log.py @@ -255,6 +255,19 @@ async def record_error( request_id or "-", fingerprint, ) + + # Live push to the platform-owner console. Best-effort like everything here: + # a failed publish must not turn a recorded error into an unrecorded one. + from app.application.ports.realtime import emit_error_recorded + + await emit_error_recorded( + error_id=event.id, + kind=event.kind, + message=event.message, + severity=str(severity), + source=str(source), + organization_id=event.organization_id, + ) return event.id diff --git a/apps/api/app/interfaces/http/routers/health.py b/apps/api/app/interfaces/http/routers/health.py index 06d9af3..8ef16a0 100644 --- a/apps/api/app/interfaces/http/routers/health.py +++ b/apps/api/app/interfaces/http/routers/health.py @@ -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} diff --git a/apps/api/app/interfaces/realtime/__init__.py b/apps/api/app/interfaces/realtime/__init__.py new file mode 100644 index 0000000..903b29d --- /dev/null +++ b/apps/api/app/interfaces/realtime/__init__.py @@ -0,0 +1,20 @@ +"""Realtime push layer — the WebSocket *interface* adapter. + +The adapter is transport and plumbing only: who is connected to which channel +(:mod:`manager`), how an event travels between processes (:mod:`bus`), which +channels a socket may subscribe to (:mod:`authz`), and the wire protocol +(:mod:`router`). + +The event contract and the typed publishers live one layer in, in +``app.application.ports.realtime`` — services depend on the port, this package +implements it. ``app.main`` is the composition root that installs the bus as +the process publisher and starts the cross-process relay. +""" + +from app.interfaces.realtime.bus import get_bus, start_relay, stop_relay + +__all__ = [ + "get_bus", + "start_relay", + "stop_relay", +] diff --git a/apps/api/app/interfaces/realtime/authz.py b/apps/api/app/interfaces/realtime/authz.py new file mode 100644 index 0000000..1783d9a --- /dev/null +++ b/apps/api/app/interfaces/realtime/authz.py @@ -0,0 +1,112 @@ +"""Channel authorization — the only gate on what a socket may receive. + +Authorization happens once, at subscribe time. After that the connection holds +plain channel names and the manager routes on them, so there is no code path +that can deliver an event to a connection that was never checked. + +Every rule resolves the channel's tenant through the repositories and then the +caller's membership in it — exactly like the HTTP layer's ``get_org_context``. +A tenant that does not own the object gets a rejection, not an empty stream: +the difference matters when debugging "why is my page not live?". + +The caller's identity arrives as two primitives (id, platform-owner flag) — +what an authenticated socket actually knows — rather than a ``User`` row, so a +subscribe costs one fewer database read. +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.repositories.identity import MembershipRepository +from app.infrastructure.repositories.sandbox import ( + SandboxSessionRepository, + WorkspaceRepository, +) +from app.infrastructure.repositories.workforce import ( + RunRepository, + TaskRepository, + ThreadRepository, +) + + +async def authorize_channel( + session: AsyncSession, + *, + user_id: str, + is_platform_owner: bool, + channel: str, +) -> bool: + """May this identity subscribe to ``channel``? Deny by default.""" + kind, _, identifier = channel.partition(":") + if not identifier and kind != "admin": + return False + + if kind == "admin": + return is_platform_owner + if kind == "user": + return identifier == user_id + if kind == "org": + return await _is_member( + session=session, user_id=user_id, organization_id=identifier + ) + if kind == "task": + return await _task_is_visible( + session=session, user_id=user_id, task_id=identifier + ) + if kind == "run": + return await _run_is_visible( + session=session, user_id=user_id, run_id=identifier + ) + if kind == "thread": + return await _thread_is_visible( + session=session, user_id=user_id, thread_id=identifier + ) + if kind == "session": + return await _session_is_visible( + session=session, user_id=user_id, session_id=identifier + ) + return False + + +async def _is_member(*, session: AsyncSession, user_id: str, organization_id: str) -> bool: + membership = await MembershipRepository(session).get(organization_id, user_id) + return membership is not None and membership.is_active + + +async def _task_is_visible(*, session: AsyncSession, user_id: str, task_id: str) -> bool: + task = await TaskRepository(session).get(task_id) + if task is None: + return False + return await _is_member( + session=session, user_id=user_id, organization_id=task.organization_id + ) + + +async def _run_is_visible(*, session: AsyncSession, user_id: str, run_id: str) -> bool: + run = await RunRepository(session).get(run_id) + if run is None: + return False + return await _task_is_visible(session=session, user_id=user_id, task_id=run.task_id) + + +async def _thread_is_visible(*, session: AsyncSession, user_id: str, thread_id: str) -> bool: + thread = await ThreadRepository(session).get(thread_id) + if thread is None: + return False + return await _is_member( + session=session, user_id=user_id, organization_id=thread.organization_id + ) + + +async def _session_is_visible(*, session: AsyncSession, user_id: str, session_id: str) -> bool: + """A sandbox session belongs to a workspace, which belongs to a tenant.""" + sandbox_session = await SandboxSessionRepository(session).get(session_id) + if sandbox_session is None: + return False + workspace = await WorkspaceRepository(session).get(sandbox_session.workspace_id) + if workspace is None: + return False + return await _is_member( + session=session, user_id=user_id, organization_id=workspace.organization_id + ) diff --git a/apps/api/app/interfaces/realtime/bus.py b/apps/api/app/interfaces/realtime/bus.py new file mode 100644 index 0000000..968ab2f --- /dev/null +++ b/apps/api/app/interfaces/realtime/bus.py @@ -0,0 +1,260 @@ +"""The event bus: how a published event reaches connected browsers. + +Two transports, in this order: + +1. **In-process.** Always on. When the API itself records the event (a task run + driven by a button, a sandbox command, an error handler), the fan-out is a + direct call into the ``ConnectionManager`` — microseconds, no broker. +2. **Redis pub/sub.** Carries events published by *other* processes: the + standalone workers (``pnpm worker:ai-runtime``) run tasks in their own + process, and without a broker their events would die there. The API process + relays whatever arrives on the shared channel into the same manager. + +Redis is a bonus, never a dependency: + +* The bus tries it once per cooldown window. If nothing is listening, the + attempt fails quietly and in-process delivery continues — the product stays + fully live for every API-driven action, only cross-process events are lost. +* The relay task owns the subscription lifecycle; it starts with the app and + stops on shutdown, and reconnects on its own after a broker restart. + +Nothing here ever raises. Publishing is called from request handlers, from +inside exception handlers and from worker passes; an exception in the feed +must not be able to fail any of those. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from contextlib import suppress + +from app.application.ports.realtime import RealtimeEvent, set_publisher +from app.core.config import settings + +logger = logging.getLogger("agy.realtime") + +#: Redis pub/sub channel shared by every process of one deployment. +DEFAULT_REDIS_CHANNEL = "agy:realtime" + +#: After a failed broker attempt, wait this long before trying again. Keeps a +#: dev machine without Redis from adding a connection timeout to every event. +REDIS_RETRY_COOLDOWN_SECONDS = 10.0 + +#: How long a single connection attempt may take before it is abandoned. +REDIS_CONNECT_TIMEOUT_SECONDS = 1.0 + +#: Listener signature: ``await listener(event)``. The ConnectionManager +#: registers one; tests register fakes. The return value is ignored so a +#: listener may report how many connections it fanned out to. +EventListener = Callable[[RealtimeEvent], Awaitable[object]] + + +class RealtimeBus: + """Fan-out hub shared by every publisher in this process.""" + + def __init__( + self, + *, + redis_url: str | None = None, + channel: str = DEFAULT_REDIS_CHANNEL, + ) -> None: + self._listeners: set[EventListener] = set() + self._redis_url = redis_url if redis_url is not None else settings.redis_url + self._channel = channel + self._redis = None + self._pubsub = None + self._relay_task: asyncio.Task | None = None + self._next_redis_attempt = 0.0 + self._redis_healthy = False + + # -- listeners (in-process fan-out) -------------------------------------- + + def attach(self, listener: EventListener) -> None: + self._listeners.add(listener) + + def detach(self, listener: EventListener) -> None: + self._listeners.discard(listener) + + # -- publishing ----------------------------------------------------------- + + async def publish(self, event: RealtimeEvent) -> None: + """Deliver locally, then mirror to other processes. Never raises.""" + try: + await self._dispatch_local(event) + except Exception: # noqa: BLE001 - the feed must never fail a caller + logger.exception("realtime: local dispatch failed for %s", event.type) + + if not self._redis_enabled(): + return + try: + await self._publish_redis(event) + except Exception: # noqa: BLE001 - same rule + logger.exception("realtime: redis publish failed for %s", event.type) + + async def _dispatch_local(self, event: RealtimeEvent) -> None: + for listener in list(self._listeners): + try: + await listener(event) + except Exception: # noqa: BLE001 - one bad listener is not fatal + logger.exception("realtime: listener failed for %s", event.type) + + def _redis_enabled(self) -> bool: + # "local" pins the bus to in-process delivery — used by tests and by + # single-process deployments that have no broker on purpose. + return getattr(settings, "realtime_transport", "auto").lower() != "local" + + async def _publish_redis(self, event: RealtimeEvent) -> None: + client = await self._redis_client() + if client is None: + return + await client.publish(self._channel, event.to_json()) + + async def _redis_client(self): + """Lazily connect, with a cooldown after failure. ``None`` if down.""" + if self._redis is not None: + return self._redis + now = time.monotonic() + if now < self._next_redis_attempt: + return None + self._next_redis_attempt = now + REDIS_RETRY_COOLDOWN_SECONDS + try: + import redis.asyncio as aioredis + + client = aioredis.from_url( + self._redis_url, + encoding="utf-8", + decode_responses=True, + socket_connect_timeout=REDIS_CONNECT_TIMEOUT_SECONDS, + socket_timeout=REDIS_CONNECT_TIMEOUT_SECONDS, + ) + await client.ping() + self._redis = client + self._redis_healthy = True + logger.info("realtime: redis pub/sub connected (%s)", self._channel) + except Exception as exc: # noqa: BLE001 - broker is optional + self._redis = None + self._redis_healthy = False + logger.debug("realtime: redis unavailable (%s); in-process only", exc) + return None + return self._redis + + # -- relay (other processes -> this one's browsers) ---------------------- + + async def start_relay(self) -> None: + """Subscribe to the shared channel and feed the local manager.""" + if self._relay_task is not None and not self._relay_task.done(): + return + self._relay_task = asyncio.create_task(self._relay_loop(), name="realtime-relay") + + async def stop_relay(self) -> None: + task = self._relay_task + self._relay_task = None + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError): + await task + await self._close_redis() + + async def _relay_loop(self) -> None: + while True: + client = await self._redis_client() + if client is None: + # Broker absent (or down mid-flight): wait out the cooldown and + # try again — a Redis started after the API must still work. + await asyncio.sleep(REDIS_RETRY_COOLDOWN_SECONDS) + continue + try: + pubsub = client.pubsub() + await pubsub.subscribe(self._channel) + self._pubsub = pubsub + logger.info("realtime: relay subscribed to %s", self._channel) + async for message in pubsub.listen(): + if message.get("type") != "message": + continue + event = _decode_message(message.get("data")) + if event is not None: + # Dedupe by id happens in the manager, so an event this + # process published itself is not delivered twice. + await self._dispatch_local(event) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - relay must survive + self._redis_healthy = False + logger.warning("realtime: relay interrupted (%s); reconnecting", exc) + finally: + if self._pubsub is not None: + with suppress(Exception): + await self._pubsub.unsubscribe(self._channel) + self._pubsub = None + self._redis = None + await asyncio.sleep(1.0) + + async def _close_redis(self) -> None: + client, self._redis = self._redis, None + if client is not None: + with suppress(Exception): + await client.aclose() + self._redis_healthy = False + + # -- introspection ------------------------------------------------------- + + @property + def redis_healthy(self) -> bool: + return self._redis_healthy + + @property + def listener_count(self) -> int: + return len(self._listeners) + + +def _decode_message(raw: object) -> RealtimeEvent | None: + if not isinstance(raw, (str, bytes)): + return None + try: + return RealtimeEvent.from_json(raw) + except Exception: # noqa: BLE001 - a bad frame is skipped, not fatal + logger.warning("realtime: undecodable relay frame dropped") + return None + + +_bus: RealtimeBus | None = None + + +def get_bus() -> RealtimeBus: + """Process-wide bus. Created lazily so importing never opens a socket.""" + global _bus + if _bus is None: + _bus = RealtimeBus() + return _bus + + +def install() -> RealtimeBus: + """Make this process's bus the application-layer publisher. + + Called by the composition roots (``app.main`` for the API, + ``app.workers.__main__`` for the workers). Idempotent, so a worker that + imports several modules pays nothing for calling it more than once. + """ + bus = get_bus() + set_publisher(bus) + return bus + + +async def publish(event: RealtimeEvent) -> None: + """Publish through the shared bus. Never raises.""" + bus = get_bus() + try: + await bus.publish(event) + except Exception: # noqa: BLE001 - belt and braces; publish is best-effort + logger.exception("realtime: publish failed for %s", event.type) + + +async def start_relay() -> None: + await get_bus().start_relay() + + +async def stop_relay() -> None: + await get_bus().stop_relay() diff --git a/apps/api/app/interfaces/realtime/manager.py b/apps/api/app/interfaces/realtime/manager.py new file mode 100644 index 0000000..fb9fc54 --- /dev/null +++ b/apps/api/app/interfaces/realtime/manager.py @@ -0,0 +1,217 @@ +"""Connection bookkeeping: who is connected, and to which channels. + +The manager is deliberately transport-agnostic — it does not know it is talking +to a WebSocket, only to an object with ``send_text``. That keeps it testable +without a socket and lets a future transport (SSE, push bridge) reuse it. + +Two invariants: + +* **A connection only ever receives channels it was authorized for.** The + authorizer runs at subscribe time; the routing table built afterwards can + only contain pre-approved channel names. +* **An event is delivered at most once.** ``dispatch`` de-duplicates by event + id, because the same event reaches a process twice in the normal case: once + in-process and once relayed from Redis. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections import deque +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass, field + +from starlette.websockets import WebSocket, WebSocketState + +from app.application.ports.realtime import RealtimeEvent + +logger = logging.getLogger("agy.realtime") + +#: Bounded dedupe memory. Large enough that a relayed duplicate is always +#: caught, small enough that a chatty day costs a few kilobytes. +MAX_SEEN_EVENTS = 2048 + +#: Per-connection outbox. If a browser stops reading (a frozen tab), a +#: producer must not be able to grow this without bound. Overflow drops the +#: oldest frame — ordering of what follows stays meaningful. +MAX_QUEUE_DEPTH = 256 + +#: What a subscribe check looks like: ``(connection, channel) -> allowed``. +ChannelAuthorizer = Callable[["Connection", str], Awaitable[bool]] + + +#: ``eq=False`` on purpose: a connection is identified by object identity. The +#: default dataclass ``__eq__`` would also make it unhashable (a ``set`` field +#: cannot be compared safely), and the manager keys connections by identity in +#: both its connection set and its channel routing table. +@dataclass(slots=True, eq=False) +class Connection: + """One authenticated browser socket.""" + + user_id: str + is_platform_owner: bool + websocket: WebSocket + channels: set[str] = field(default_factory=set) + _queue: asyncio.Queue[str] = field( + default_factory=lambda: asyncio.Queue(maxsize=MAX_QUEUE_DEPTH) + ) + _sender: asyncio.Task | None = None + + async def start(self) -> None: + """Single writer task per connection. + + One task owns the socket: concurrent ``send_text`` calls on one + WebSocket interleave frames and corrupt the stream, so everything goes + through this queue instead. + """ + if self._sender is None or self._sender.done(): + self._sender = asyncio.create_task(self._drain(), name="realtime-sender") + + async def _drain(self) -> None: + while True: + payload = await self._queue.get() + if payload is None: # shutdown sentinel + return + try: + if self.websocket.client_state == WebSocketState.CONNECTED: + await self.websocket.send_text(payload) + except Exception: # noqa: BLE001 - a dead socket ends the connection + logger.debug("realtime: send failed; closing connection", exc_info=True) + return + + def offer(self, payload: str) -> None: + """Queue a frame, never blocking the publisher. + + A slow consumer loses the oldest frames rather than stalling every + other tenant's stream behind it. + """ + if self._queue.full(): + with suppress(asyncio.QueueFull): + # Drop the oldest frame to make room: ordering of what follows + # stays meaningful, which is what the UI renders. + self._queue.get_nowait() + with suppress(asyncio.QueueFull): # pragma: no cover - defensive + self._queue.put_nowait(payload) + + async def close(self) -> None: + if self._sender is not None: + self._sender.cancel() + self._sender = None + + +class ConnectionManager: + """The routing table between events and open sockets.""" + + def __init__(self) -> None: + self._connections: set[Connection] = set() + self._by_channel: dict[str, set[Connection]] = {} + self._seen: deque[str] = deque(maxlen=MAX_SEEN_EVENTS) + self._seen_set: set[str] = set() + + # -- lifecycle ----------------------------------------------------------- + + async def register(self, connection: Connection) -> None: + self._connections.add(connection) + await connection.start() + + async def unregister(self, connection: Connection) -> None: + self._connections.discard(connection) + for channel in list(connection.channels): + subscribers = self._by_channel.get(channel) + if subscribers is not None: + subscribers.discard(connection) + if not subscribers: + self._by_channel.pop(channel, None) + connection.channels.clear() + await connection.close() + + # -- subscriptions --------------------------------------------------------- + + async def subscribe( + self, + connection: Connection, + channels: list[str], + authorizer: ChannelAuthorizer, + ) -> tuple[list[str], list[str]]: + """Authorize and subscribe. Returns ``(accepted, rejected)``.""" + accepted: list[str] = [] + rejected: list[str] = [] + for channel in channels: + if channel in connection.channels: + accepted.append(channel) + continue + try: + allowed = await authorizer(connection, channel) + except Exception: # noqa: BLE001 - a broken check denies, never allows + logger.exception("realtime: channel authorization crashed") + allowed = False + if not allowed: + rejected.append(channel) + continue + connection.channels.add(channel) + self._by_channel.setdefault(channel, set()).add(connection) + accepted.append(channel) + return accepted, rejected + + async def unsubscribe(self, connection: Connection, channels: list[str]) -> None: + for channel in channels: + connection.channels.discard(channel) + subscribers = self._by_channel.get(channel) + if subscribers is not None: + subscribers.discard(connection) + if not subscribers: + self._by_channel.pop(channel, None) + + # -- fan-out --------------------------------------------------------------- + + async def dispatch(self, event: RealtimeEvent) -> int: + """Deliver to every subscriber of every channel. Returns fan-out count. + + The id check is what makes "publish locally, then receive the same event + from Redis" deliver once instead of twice. + """ + if event.id in self._seen_set: + return 0 + self._remember(event.id) + + recipients: set[Connection] = set() + for channel in event.channels: + recipients.update(self._by_channel.get(channel, ())) + + envelope = event.envelope() + # Control frames and event frames share one top-level discriminator + # (``type``), so the client switches on one field for everything. + payload = _dumps({"type": "event", "event": envelope}) + delivered = 0 + for connection in recipients: + connection.offer(payload) + delivered += 1 + return delivered + + def _remember(self, event_id: str) -> None: + if len(self._seen) == self._seen.maxlen: + evicted = self._seen[0] + self._seen_set.discard(evicted) + self._seen.append(event_id) + self._seen_set.add(event_id) + + # -- introspection ------------------------------------------------------- + + @property + def connection_count(self) -> int: + return len(self._connections) + + @property + def channel_count(self) -> int: + return len(self._by_channel) + + def channel_load(self) -> dict[str, int]: + return {channel: len(subs) for channel, subs in self._by_channel.items()} + + +def _dumps(envelope: dict) -> str: + import json + + return json.dumps(envelope, ensure_ascii=False, default=str) diff --git a/apps/api/app/interfaces/realtime/router.py b/apps/api/app/interfaces/realtime/router.py new file mode 100644 index 0000000..6a4d91b --- /dev/null +++ b/apps/api/app/interfaces/realtime/router.py @@ -0,0 +1,175 @@ +"""The WebSocket endpoint — one socket per browser, every live feed on it. + +``GET /api/ws?token=`` + +The token travels as a query parameter because the browser WebSocket API cannot +set an ``Authorization`` header. That is a deliberate, contained exception to +the header-only rule: the token is short-lived, the socket is TLS-terminated in +any real deployment, and the alternative (a cookie) would drag CSRF into a +protocol that has no CSRF story. + +Protocol — small, symmetric, JSON in both directions: + +.. code-block:: text + + client -> {"action": "subscribe", "channels": ["org:…", "task:…"]} + client -> {"action": "unsubscribe", "channels": ["task:…"]} + client -> {"action": "ping"} + server -> {"type": "hello", "user_id": "…"} + server -> {"type": "subscribed", "accepted": [...], "rejected": [...]} + server -> {"type": "event", "event": {…envelope…}} + server -> {"type": "pong"} + server -> {"type": "error", "message": "…"} + +A rejected channel is reported, not hidden: "my task page is not live" must be +answerable from the browser console alone. + +Authorization queries open their own short-lived session: a socket can stay +open for hours, and one SQLAlchemy session per open tab would quietly exhaust +the connection pool. +""" + +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from app.application.ports.realtime import dedupe_channels +from app.core.database import SessionFactory +from app.core.errors import AuthenticationError +from app.core.security import decode_access_token +from app.infrastructure.repositories.identity import UserRepository +from app.interfaces.realtime.authz import authorize_channel +from app.interfaces.realtime.manager import Connection, ConnectionManager + +logger = logging.getLogger("agy.realtime") + +router = APIRouter() + +#: The manager instance this process fans out with. Created at import: the +#: endpoint and the lifecycle hooks can share it without a wiring ceremony. +_manager = ConnectionManager() + + +def realtime_manager() -> ConnectionManager: + """The process-wide manager — attached to the bus by ``app.main``.""" + return _manager + + +@router.websocket("/ws") +async def realtime_socket( + websocket: WebSocket, + token: str | None = Query(default=None), +) -> None: + connection = await _authenticate(websocket, token) + if connection is None: + return # _authenticate already closed with a policy-violation code + + await _manager.register(connection) + + await _send( + websocket, + {"type": "hello", "user_id": connection.user_id, "server_time": _now_iso()}, + ) + + try: + while True: + raw = await websocket.receive_text() + await _handle_message(websocket, connection, raw) + except WebSocketDisconnect: + pass + except Exception: # noqa: BLE001 - any read failure ends the socket cleanly + logger.debug("realtime: socket loop ended", exc_info=True) + finally: + await _manager.unregister(connection) + + +async def _authenticate(websocket: WebSocket, token: str | None) -> Connection | None: + """Accept only a valid, active user. Closes 1008 otherwise.""" + user = None + if token: + try: + payload = decode_access_token(token) + user_id = payload.get("sub") + if user_id: + # Own session: the socket must not depend on request scope. + async with SessionFactory() as session: + user = await UserRepository(session).get(str(user_id)) + except AuthenticationError: + user = None + if user is None or not user.is_active: + # 1008 (policy violation) rather than a bare close: the client's + # reconnect logic can then tell "bad token" from "network dropped". + await websocket.close(code=1008, reason="unauthorized") + return None + await websocket.accept() + return Connection( + user_id=user.id, + is_platform_owner=user.is_platform_owner, + websocket=websocket, + ) + + +async def _handle_message(websocket: WebSocket, connection: Connection, raw: str) -> None: + try: + message = json.loads(raw) + except (TypeError, ValueError): + await _send(websocket, {"type": "error", "message": "malformed JSON frame"}) + return + if not isinstance(message, dict): + await _send(websocket, {"type": "error", "message": "frame must be an object"}) + return + + action = message.get("action") + + if action == "ping": + await _send(websocket, {"type": "pong"}) + return + + channels = dedupe_channels( + str(channel) for channel in (message.get("channels") or []) if isinstance(channel, str) + ) + + if action == "subscribe": + accepted, rejected = await _subscribe(connection, channels) + await _send( + websocket, {"type": "subscribed", "accepted": accepted, "rejected": rejected} + ) + return + + if action == "unsubscribe": + await _manager.unsubscribe(connection, list(channels)) + await _send(websocket, {"type": "unsubscribed", "channels": channels}) + return + + await _send(websocket, {"type": "error", "message": f"unknown action: {action!r}"}) + + +async def _subscribe( + connection: Connection, channels: tuple[str, ...] +) -> tuple[list[str], list[str]]: + if not channels: + return [], [] + + async def _authorize(conn: Connection, channel: str) -> bool: + async with SessionFactory() as session: + return await authorize_channel( + session, + user_id=conn.user_id, + is_platform_owner=conn.is_platform_owner, + channel=channel, + ) + + return await _manager.subscribe(connection, list(channels), _authorize) + + +def _now_iso() -> str: + from app.domain.base import utcnow + + return utcnow().isoformat() + + +async def _send(websocket: WebSocket, payload: dict) -> None: + await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str)) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 94fe661..ba4bc41 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -27,6 +27,10 @@ from app.interfaces.http.routers import ( sandbox, workforce, ) +from app.interfaces.realtime.bus import install as install_realtime_bus +from app.interfaces.realtime.bus import start_relay, stop_relay +from app.interfaces.realtime.router import realtime_manager +from app.interfaces.realtime.router import router as realtime_router logging.basicConfig( level=logging.INFO, @@ -62,8 +66,19 @@ async def lifespan(_: FastAPI): # loudly — a half-started API must not accept traffic. await _record_startup_failure(exc) raise + + # --- realtime wiring --------------------------------------------------- + # This process is the publisher for API-driven events and the relay for + # anything the standalone workers publish. Both are best-effort: if Redis + # is not reachable the in-process fan-out keeps every HTTP-driven feed live. + bus = install_realtime_bus() + bus.attach(realtime_manager().dispatch) + await start_relay() + yield + await stop_relay() + async def _record_startup_failure(exc: BaseException) -> None: """Persist a boot failure. Never raises; the caller re-raises regardless.""" @@ -123,6 +138,8 @@ def create_app() -> FastAPI: application.include_router(sandbox.router, prefix="/api") application.include_router(admin.router, prefix="/api") application.include_router(agy.router, prefix="/api") + # The live feed: one WebSocket per browser, every push on it. + application.include_router(realtime_router, prefix="/api") @application.get("/", tags=["meta"]) async def root() -> dict: diff --git a/apps/api/app/workers/__main__.py b/apps/api/app/workers/__main__.py index 612c02f..301e5fb 100644 --- a/apps/api/app/workers/__main__.py +++ b/apps/api/app/workers/__main__.py @@ -125,6 +125,11 @@ def main(argv: list[str] | None = None) -> int: format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) args = _build_parser().parse_args(argv) + # A worker is its own process: publishing here is how a run it starts, or a + # session it reaps, reaches the browsers connected to the API process. + from app.interfaces.realtime.bus import install as install_realtime + + install_realtime() return asyncio.run(_main_async(args)) diff --git a/apps/api/app/workers/ai_runtime.py b/apps/api/app/workers/ai_runtime.py index a5e5589..110728c 100644 --- a/apps/api/app/workers/ai_runtime.py +++ b/apps/api/app/workers/ai_runtime.py @@ -14,6 +14,7 @@ the service's business. from __future__ import annotations from app.application.container import build_workforce_service +from app.application.ports.realtime import emit_worker_status from app.core.errors import AppError from app.domain.audit.models import ErrorSeverity, ErrorSource from app.domain.workforce.models import TaskStatus @@ -87,6 +88,13 @@ class AiRuntimeWorker: outcome.processed += 1 outcome.note(f"task {task.id} -> run {run.id} ({run.status})") + # This worker is its own process: without a push here, a run + # started by the queue is invisible until someone reloads. + await emit_worker_status( + organization_id=task.organization_id, + worker=self.name, + message=f"کار «{current.title}» در حال اجراست (اجرا {run.id}).", + ) return outcome diff --git a/apps/api/app/workers/sandbox_gateway.py b/apps/api/app/workers/sandbox_gateway.py index d6d218e..06a6b19 100644 --- a/apps/api/app/workers/sandbox_gateway.py +++ b/apps/api/app/workers/sandbox_gateway.py @@ -21,6 +21,7 @@ from __future__ import annotations from datetime import timedelta from app.application.container import build_sandbox_service +from app.application.ports.realtime import emit_sandbox_reaped from app.core.config import settings from app.domain.audit.models import AuditAction, AuditEvent from app.domain.base import utcnow @@ -72,6 +73,13 @@ class SandboxGatewayWorker: ) outcome.processed += 1 outcome.note(f"stopped session {sandbox_session.id}") + # A reaped sandbox is a tenant-visible fact: push it so the + # project page's terminal stops showing a live session. + await emit_sandbox_reaped( + organization_id=workspace.organization_id if workspace else None, + session_id=sandbox_session.id, + workspace_id=sandbox_session.workspace_id, + ) if not stopped: outcome.skipped += 1 diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index f8b54c6..358471f 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -39,6 +39,18 @@ def _production_shaped_jwt_secret(): settings.jwt_secret = "test-only-jwt-secret-with-more-than-32-bytes" +@pytest.fixture(scope="session", autouse=True) +def _hermetic_realtime_transport(): + """Pin the realtime bus to in-process delivery. + + ``auto`` would make every publish attempt a Redis connection to + ``localhost:6379`` — the suite has no broker, so each event would add a + connection timeout and log noise. The fan-out behaviour under test does not + depend on the transport. + """ + settings.realtime_transport = "local" + + @pytest_asyncio.fixture async def engine(): # StaticPool keeps one connection alive so the in-memory schema survives diff --git a/apps/api/tests/test_realtime.py b/apps/api/tests/test_realtime.py new file mode 100644 index 0000000..aaa19a9 --- /dev/null +++ b/apps/api/tests/test_realtime.py @@ -0,0 +1,416 @@ +"""Realtime push layer: authorization, fan-out, and the live socket end-to-end. + +Three levels, mirroring the package's structure: + +* **Authorization** — a channel is only ever granted through + ``authorize_channel``; every rule is tested for both the allow and the deny + side, because a rule that always denies is indistinguishable from a bug. +* **Fan-out** — the manager delivers to the subscribers of a channel, keeps + ordering, de-duplicates relayed events and cleans up on unsubscribe. +* **The socket** — a real WebSocket against the real app: subscribe, act over + HTTP, and assert the event arrives without a reload. +""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +import app.infrastructure.db.models as _orm_models # noqa: F401 - registers metadata +from app.application.ports.realtime import RealtimeEvent, set_publisher +from app.core.config import settings +from app.core.database import Base, get_session +from app.core.security import create_access_token +from app.domain.base import new_id +from app.infrastructure.db.models import UserRow +from app.interfaces.realtime.authz import authorize_channel +from app.interfaces.realtime.bus import get_bus +from app.interfaces.realtime.manager import Connection, ConnectionManager +from app.main import app + +API_ROOT = Path(__file__).resolve().parents[1] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeSocket: + """The manager only needs ``send_text`` and a connected ``client_state``. + + Both matter: the sender task checks ``client_state`` before writing, so a + fake without it would silently swallow every frame and make the manager + look broken. + """ + + def __init__(self) -> None: + self.sent: list[str] = [] + from starlette.websockets import WebSocketState + + self.client_state = WebSocketState.CONNECTED + + async def send_text(self, payload: str) -> None: + self.sent.append(payload) + + +def _event(event_id: str, *channels: str) -> RealtimeEvent: + return RealtimeEvent(type="test.event", channels=channels, data={"id": event_id}, id=event_id) + + +async def _allow(_: Connection, __: str) -> bool: + return True + + +# --------------------------------------------------------------------------- +# Authorization +# --------------------------------------------------------------------------- + + +async def _user_with_org(session: AsyncSession, *, platform_owner: bool = False): + """A user, and optionally an organization they belong to.""" + user_id = new_id() + session.add( + UserRow( + id=user_id, + email=f"{user_id}@example.com", + password_hash="x", + is_platform_owner=platform_owner, + ) + ) + await session.flush() + return user_id + + +async def _org_with_member(session: AsyncSession, user_id: str) -> str: + from app.domain.identity.models import Membership, Organization, Role + from app.infrastructure.repositories.identity import ( + MembershipRepository, + OrganizationRepository, + ) + + organization = await OrganizationRepository(session).create( + Organization(name="سازمان تست", slug=new_id()[:8], owner_user_id=user_id) + ) + await MembershipRepository(session).add( + Membership(organization_id=organization.id, user_id=user_id, role=Role.ADMIN) + ) + return organization.id + + +@pytest.mark.asyncio +async def test_member_may_subscribe_to_own_org(session_factory): + async with session_factory() as session: + user_id = await _user_with_org(session) + org_id = await _org_with_member(session, user_id) + allowed = await authorize_channel( + session, user_id=user_id, is_platform_owner=False, channel=f"org:{org_id}" + ) + assert allowed is True + + +@pytest.mark.asyncio +async def test_outsider_may_not_subscribe_to_foreign_org(session_factory): + async with session_factory() as session: + insider = await _user_with_org(session) + outsider = await _user_with_org(session) + org_id = await _org_with_member(session, insider) + allowed = await authorize_channel( + session, user_id=outsider, is_platform_owner=False, channel=f"org:{org_id}" + ) + assert allowed is False + + +@pytest.mark.asyncio +async def test_unknown_channel_kinds_are_denied(session_factory): + async with session_factory() as session: + user_id = await _user_with_org(session) + allowed = await authorize_channel( + session, user_id=user_id, is_platform_owner=False, channel="nonsense:whatever" + ) + assert allowed is False + + +@pytest.mark.asyncio +async def test_admin_channel_needs_platform_owner(session_factory): + async with session_factory() as session: + owner = await _user_with_org(session, platform_owner=True) + member = await _user_with_org(session) + owner_ok = await authorize_channel( + session, user_id=owner, is_platform_owner=True, channel="admin" + ) + member_ok = await authorize_channel( + session, user_id=member, is_platform_owner=False, channel="admin" + ) + assert owner_ok is True + assert member_ok is False + + +@pytest.mark.asyncio +async def test_user_channel_is_private(session_factory): + async with session_factory() as session: + user_id = await _user_with_org(session) + other = await _user_with_org(session) + own = await authorize_channel( + session, user_id=user_id, is_platform_owner=False, channel=f"user:{user_id}" + ) + foreign = await authorize_channel( + session, user_id=other, is_platform_owner=False, channel=f"user:{user_id}" + ) + assert own is True + assert foreign is False + + +# --------------------------------------------------------------------------- +# Fan-out +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_manager_delivers_to_subscribers_only(): + manager = ConnectionManager() + listening = FakeSocket() + other = FakeSocket() + + subscribed = Connection(user_id="u1", is_platform_owner=False, websocket=listening) + stranger = Connection(user_id="u2", is_platform_owner=False, websocket=other) + for connection in (subscribed, stranger): + await manager.register(connection) + await manager.subscribe(subscribed, ["org:1"], _allow) + + delivered = await manager.dispatch(_event("e1", "org:1")) + assert delivered == 1 + + # The write happens on the connection's own sender task; give the loop the + # turn it needs to flush the queue. + await asyncio.sleep(0.05) + assert len(listening.sent) == 1 + assert other.sent == [] + + +@pytest.mark.asyncio +async def test_manager_wraps_events_in_the_wire_envelope(): + """A browser sees one discriminator: ``{"type": "event", "event": {...}}``.""" + import json + + manager = ConnectionManager() + socket = FakeSocket() + connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket) + await manager.register(connection) + await manager.subscribe(connection, ["org:1"], _allow) + await manager.dispatch(_event("e1", "org:1")) + + await asyncio.sleep(0.05) + assert len(socket.sent) == 1 + frame = json.loads(socket.sent[0]) + assert frame["type"] == "event" + assert frame["event"]["id"] == "e1" + assert frame["event"]["type"] == "test.event" + + +@pytest.mark.asyncio +async def test_manager_drops_duplicate_events(): + """In-process publish plus a relayed copy deliver exactly once.""" + manager = ConnectionManager() + socket = FakeSocket() + connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket) + await manager.register(connection) + await manager.subscribe(connection, ["org:1"], _allow) + + event = _event("dup", "org:1") + first = await manager.dispatch(event) + second = await manager.dispatch(event) + assert (first, second) == (1, 0) + + +@pytest.mark.asyncio +async def test_unsubscribe_stops_delivery(): + manager = ConnectionManager() + socket = FakeSocket() + connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket) + await manager.register(connection) + await manager.subscribe(connection, ["org:1", "org:2"], _allow) + await manager.unsubscribe(connection, ["org:1"]) + + assert await manager.dispatch(_event("e1", "org:1")) == 0 + assert await manager.dispatch(_event("e2", "org:2")) == 1 + + +@pytest.mark.asyncio +async def test_publisher_never_raises_on_bad_listener(): + """A listener that explodes must not break the publisher or its peers.""" + + async def exploding(event: RealtimeEvent) -> None: + raise RuntimeError("boom") + + bus = get_bus() + bus.attach(exploding) + try: + set_publisher(bus) + # Must not raise despite the exploding listener. + from app.application.ports.realtime import publish + + await publish(_event("e1", "org:1")) + finally: + bus.detach(exploding) + set_publisher(_NullPublisher()) + + +class _NullPublisher: + async def publish(self, event: RealtimeEvent) -> None: + return None + + +# --------------------------------------------------------------------------- +# The socket, end to end +# --------------------------------------------------------------------------- + + +async def _create_schema(engine) -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +@pytest.fixture() +def ws_client(monkeypatch): + """A TestClient whose API and socket both talk to one throwaway database. + + File-based SQLite with ``NullPool`` on purpose: the app runs on the test + portal's event loop while the schema is created from the test's own, and a + pooled connection cannot cross that boundary. The database file lives in + the package directory — ``*.db`` is gitignored — so the fixture needs no + writable temp directory outside the workspace. + """ + db_path = API_ROOT / f"realtime-test-{new_id()}.db" + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", poolclass=NullPool) + asyncio.run(_create_schema(engine)) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + monkeypatch.setattr("app.main.engine", engine) + monkeypatch.setattr("app.interfaces.realtime.router.SessionFactory", factory) + monkeypatch.setattr(settings, "realtime_transport", "local") + + async def _override_session(): + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + app.dependency_overrides[get_session] = _override_session + # The manager is module state; start each run from a clean slate so a + # connection from another test cannot colour this one's fan-out. + from app.interfaces.realtime.router import realtime_manager + + realtime_manager()._connections.clear() + realtime_manager()._by_channel.clear() + try: + with TestClient(app) as client: + yield client + finally: + app.dependency_overrides.clear() + asyncio.run(engine.dispose()) + with suppress(OSError): + db_path.unlink() + + +def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict: + """Read frames until ``wanted_type`` arrives — hello/subscribed come first.""" + for _ in range(max_frames): + frame = websocket.receive_json() + if frame.get("type") == "event" and frame.get("event", {}).get("type") == wanted_type: + return frame["event"] + raise AssertionError(f"no {wanted_type} event arrived") + + +def test_socket_rejects_a_missing_token(ws_client): + with pytest.raises(Exception), ws_client.websocket_connect("/api/ws"): # noqa: B017, SIM117 + # No token: the endpoint closes with 1008 before accepting. + pass + + +def test_task_creation_is_pushed_live(ws_client): + # Sign up, create an organization — the tenant the socket subscribes to. + signup = ws_client.post( + "/api/auth/signup", + json={"email": "live@example.com", "password": "password123", "name": "زنده"}, + ) + assert signup.status_code == 201, signup.text + token = signup.json()["access_token"] + org = ws_client.post( + "/api/organizations", + json={"name": "سازمان زنده"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert org.status_code == 201, org.text + org_id = org.json()["id"] + + with ws_client.websocket_connect(f"/api/ws?token={token}") as socket: + hello = socket.receive_json() + assert hello["type"] == "hello" + + socket.send_json({"action": "subscribe", "channels": [f"org:{org_id}"]}) + subscribed = socket.receive_json() + assert subscribed["type"] == "subscribed" + assert subscribed["accepted"] == [f"org:{org_id}"] + + # A foreign channel is refused, not silently ignored. + socket.send_json({"action": "subscribe", "channels": ["org:does-not-exist"]}) + refused = socket.receive_json() + assert refused["type"] == "subscribed" + assert refused["rejected"] == ["org:does-not-exist"] + + created = ws_client.post( + f"/api/organizations/{org_id}/tasks", + json={"title": "کار زنده"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert created.status_code == 201, created.text + + event = _frames_until(socket, "task.created") + assert event["data"]["title"] == "کار زنده" + assert event["organization_id"] == org_id + + +def test_second_user_cannot_read_another_org(ws_client): + """Two tenants, one socket each: an outsider's org is out of reach.""" + tokens = [] + org_ids = [] + for index in range(2): + signup = ws_client.post( + "/api/auth/signup", + json={"email": f"user{index}@example.com", "password": "password123"}, + ) + assert signup.status_code == 201, signup.text + token = signup.json()["access_token"] + org = ws_client.post( + "/api/organizations", + json={"name": f"org {index}"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert org.status_code == 201, org.text + tokens.append(token) + org_ids.append(org.json()["id"]) + + with ws_client.websocket_connect(f"/api/ws?token={tokens[0]}") as socket: + socket.receive_json() # hello + socket.send_json({"action": "subscribe", "channels": [f"org:{org_ids[1]}"]}) + refused = socket.receive_json() + assert refused["rejected"] == [f"org:{org_ids[1]}"] + + +def test_socket_token_identifies_a_real_user(ws_client): + """A well-formed token for a deleted user must not open a socket.""" + user_id = new_id() + token = create_access_token(user_id) + with pytest.raises(Exception), ws_client.websocket_connect(f"/api/ws?token={token}"): # noqa: B017, SIM117 + # Closed with 1008: the token is well-formed but names no user. + pass diff --git a/apps/web/src/app/(app)/admin/errors/page.tsx b/apps/web/src/app/(app)/admin/errors/page.tsx index fc91351..aabbff5 100644 --- a/apps/web/src/app/(app)/admin/errors/page.tsx +++ b/apps/web/src/app/(app)/admin/errors/page.tsx @@ -25,6 +25,7 @@ import { } from "@agy/ui"; import type { TableColumn } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; +import { useLiveChannel } from "@/lib/realtime"; /** * Platform-owner error console. @@ -101,6 +102,63 @@ export default function AdminErrorsPage() { const [busy, setBusy] = React.useState(null); const [notice, setNotice] = React.useState(null); + // Live feed: new errors appear in the console as they happen. + useLiveChannel(["admin"], (event) => { + if (event.type === "error.recorded") { + const data = event.data as { + error_id: string | null; + severity: ErrorSeverity; + source: ErrorSource; + kind: string; + message: string; + organization_id: string | null; + }; + const id = data.error_id ?? event.id; + setEvents((current) => { + if (current.some((e) => e.id === id)) return current; + return [ + { + id, + kind: data.kind, + message: data.message, + stack: "", + severity: data.severity, + source: data.source, + error_type: data.kind, + fingerprint: id, + status_code: null, + method: "", + path: "", + organization_id: data.organization_id, + user_id: null, + run_id: null, + request_id: "", + ip: "", + user_agent: "", + metadata_json: {}, + resolved_at: null, + resolved_note: "", + created_at: new Date().toISOString(), + }, + ...current, + ]; + }); + setTotal((current) => current + 1); + setSummary((current) => + current + ? { + ...current, + total_open: current.total_open + 1, + open_by_severity: { + ...current.open_by_severity, + [data.severity]: (current.open_by_severity[data.severity] ?? 0) + 1, + }, + } + : current, + ); + } + }); + const load = React.useCallback(async () => { setLoading(true); setError(null); diff --git a/apps/web/src/app/(app)/billing/page.tsx b/apps/web/src/app/(app)/billing/page.tsx index adbcaee..ea0e52c 100644 --- a/apps/web/src/app/(app)/billing/page.tsx +++ b/apps/web/src/app/(app)/billing/page.tsx @@ -23,6 +23,7 @@ import { } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions"; +import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; const UNIT_LABELS: Record = { @@ -69,6 +70,44 @@ export default function BillingPage() { const [actionError, setActionError] = React.useState(null); const [checkoutUrl, setCheckoutUrl] = React.useState(null); + // Live feed: wallet balance and usage events update as they happen. + useLiveChannel( + orgId ? [`org:${orgId}`] : [], + (event) => { + switch (event.type) { + case "billing.wallet": { + const data = event.data as { organization_id: string; balance: number }; + setSummary((current) => + current ? { ...current, balance: data.balance, low_balance: Boolean(data.balance <= current.low_balance_threshold) } : current, + ); + break; + } + case "usage.recorded": { + const data = event.data as { + run_id: string | null; + unit: string; + quantity: number; + }; + setEvents((current) => [ + { + id: event.id, + run_id: data.run_id, + unit: data.unit as UsageEvent["unit"], + quantity: data.quantity, + unit_price: 0, + total_price: 0, + created_at: new Date().toISOString(), + }, + ...current, + ]); + break; + } + default: + break; + } + }, + ); + const load = React.useCallback(async () => { if (!orgId) { setLoading(false); diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index 1a8d0a8..692cc5e 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -15,6 +15,7 @@ import { UsageBar, } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; +import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; export default function DashboardPage() { @@ -24,6 +25,32 @@ export default function DashboardPage() { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); + // Live feed: audit events stream into the timeline; usage updates the bar. + useLiveChannel( + activeOrganization ? [`org:${activeOrganization.id}`] : [], + (event) => { + switch (event.type) { + case "audit.recorded": { + const data = event.data as { action: string; target_type: string; target_id: string }; + setAudit((current) => [ + { + id: event.id, + action: data.action, + actor_user_id: null, + target_type: data.target_type, + target_id: data.target_id, + created_at: new Date().toISOString(), + }, + ...current, + ]); + break; + } + default: + break; + } + }, + ); + const load = React.useCallback(async () => { setLoading(true); setError(null); diff --git a/apps/web/src/app/(app)/manager/page.tsx b/apps/web/src/app/(app)/manager/page.tsx index 703265e..54f764f 100644 --- a/apps/web/src/app/(app)/manager/page.tsx +++ b/apps/web/src/app/(app)/manager/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import * as React from "react"; -import type { Employee, Message, Task, Thread } from "@agy/contracts"; +import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts"; import { Badge, Button, @@ -17,6 +17,7 @@ import { } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; +import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; /** @@ -48,6 +49,63 @@ export default function ManagerPage() { const bottomRef = React.useRef(null); + // Live feed: new messages in the active thread, and tasks created from chat. + const liveChannels = React.useMemo(() => { + const base: string[] = []; + if (orgId) base.push(`org:${orgId}`); + if (activeThreadId) base.push(`thread:${activeThreadId}`); + return base; + }, [orgId, activeThreadId]); + + useLiveChannel(liveChannels, (event) => { + switch (event.type) { + case "message.created": { + const data = event.data as { + thread_id: string; + message: { id: string; role: string; content: string; employee_id?: string | null }; + }; + if (data.thread_id === activeThreadId) { + setMessages((current) => [ + ...current, + { + id: data.message.id, + thread_id: data.thread_id, + role: data.message.role as Message["role"], + content: data.message.content, + employee_id: data.message.employee_id ?? null, + created_at: new Date().toISOString(), + }, + ]); + } + break; + } + case "task.created": { + const data = event.data as { task_id: string; title: string; status: string }; + setTasks((current) => { + if (current.some((t) => t.id === data.task_id)) return current; + return [ + { + id: data.task_id, + organization_id: orgId ?? "", + project_id: null, + thread_id: activeThreadId, + title: data.title, + description: "", + status: data.status as TaskStatus, + assigned_employee_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ...current, + ]; + }); + break; + } + default: + break; + } + }); + const load = React.useCallback(async () => { if (!orgId) { setLoading(false); diff --git a/apps/web/src/app/(app)/tasks/[id]/page.tsx b/apps/web/src/app/(app)/tasks/[id]/page.tsx index d5970f3..9eb86bd 100644 --- a/apps/web/src/app/(app)/tasks/[id]/page.tsx +++ b/apps/web/src/app/(app)/tasks/[id]/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import * as React from "react"; -import type { Employee, RunDetail, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts"; +import type { Employee, RunDetail, RunStatus, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts"; import { Badge, Button, @@ -17,6 +17,7 @@ import { } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; +import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; const STATUS_LABELS: Record = { @@ -64,6 +65,112 @@ export default function TaskDetailPage({ const [running, setRunning] = React.useState(false); const [actionError, setActionError] = React.useState(null); + // Live feed: task status, run steps, approvals, artifacts — all push. + const channels = React.useMemo(() => { + const base: string[] = []; + if (orgId) base.push(`org:${orgId}`); + if (taskId) base.push(`task:${taskId}`); + if (selectedRun?.id) base.push(`run:${selectedRun.id}`); + return base; + }, [orgId, taskId, selectedRun?.id]); + + useLiveChannel(channels, (event) => { + switch (event.type) { + case "task.status": { + const data = event.data as { task_id: string; status: string }; + if (data.task_id === taskId) { + setTask((current) => (current ? { ...current, status: data.status as TaskStatus } : current)); + } + break; + } + case "run.step": { + const data = event.data as { + run_id: string; + task_id: string; + step: { id: string; index: number; kind: string; title: string; payload: Record }; + }; + setSelectedRun((current) => { + if (!current || current.id !== data.run_id) return current; + return { + ...current, + steps: [ + ...current.steps, + { + id: data.step.id, + index: data.step.index, + kind: data.step.kind, + title: data.step.title, + payload_json: data.step.payload, + created_at: new Date().toISOString(), + }, + ], + }; + }); + break; + } + case "run.status": { + const data = event.data as { run_id: string; task_id: string; status: string }; + setSelectedRun((current) => + current && current.id === data.run_id + ? { ...current, status: data.status as RunDetail["status"] } + : current, + ); + setTask((current) => { + if (!current) return current; + return { + ...current, + runs: current.runs.map((r) => + r.id === data.run_id ? { ...r, status: data.status as RunStatus } : r, + ), + }; + }); + break; + } + case "approval.pending": { + const data = event.data as { tool_call_id: string; tool_key: string; run_id: string }; + setApprovals((current) => { + if (current.some((a) => a.id === data.tool_call_id)) return current; + return [ + ...current, + { + id: data.tool_call_id, + tool_key: data.tool_key, + status: "awaiting_approval", + requires_approval: true, + arguments_json: {}, + result_json: {}, + created_at: new Date().toISOString(), + }, + ]; + }); + break; + } + case "artifact.created": { + const data = event.data as { artifact_id: string; run_id: string; name: string }; + setSelectedRun((current) => { + if (!current || current.id !== data.run_id) return current; + return { + ...current, + artifacts: [ + ...current.artifacts, + { + id: data.artifact_id, + name: data.name, + kind: "text", + size_bytes: 0, + content_ref: "", + created_at: new Date().toISOString(), + }, + ], + }; + }); + break; + } + default: + break; + } + }); + const load = React.useCallback(async () => { if (!orgId) { setLoading(false); diff --git a/apps/web/src/app/(app)/traces/page.tsx b/apps/web/src/app/(app)/traces/page.tsx index 4deb9d9..2ade898 100644 --- a/apps/web/src/app/(app)/traces/page.tsx +++ b/apps/web/src/app/(app)/traces/page.tsx @@ -18,7 +18,7 @@ import Link from "next/link"; import * as React from "react"; -import type { AuditEvent, Run, RunDetail } from "@agy/contracts"; +import type { AuditEvent, Run, RunDetail, RunStatus } from "@agy/contracts"; import { Badge, Button, @@ -32,6 +32,7 @@ import { Timeline, } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; +import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; /** How many tasks to fan out into. Bounds the request count. */ @@ -108,6 +109,71 @@ export default function TracesPage() { const [error, setError] = React.useState(null); const [detailLoading, setDetailLoading] = React.useState(false); + // Live feed: run steps and status changes update both the list and the detail. + useLiveChannel( + orgId ? [`org:${orgId}`] : [], + (event) => { + switch (event.type) { + case "run.step": { + const data = event.data as { + run_id: string; + task_id: string; + step: { id: string; index: number; kind: string; title: string; payload: Record }; + }; + setSelected((current) => { + if (!current || current.id !== data.run_id) return current; + return { + ...current, + steps: [ + ...current.steps, + { + id: data.step.id, + index: data.step.index, + kind: data.step.kind, + title: data.step.title, + payload_json: data.step.payload, + created_at: new Date().toISOString(), + }, + ], + }; + }); + break; + } + case "run.status": { + const data = event.data as { run_id: string; task_id: string; status: string }; + setRows((current) => + current.map((row) => + row.run.id === data.run_id ? { ...row, run: { ...row.run, status: data.status as RunStatus } } : row, + ), + ); + setSelected((current) => + current && current.id === data.run_id + ? { ...current, status: data.status as RunDetail["status"] } + : current, + ); + break; + } + case "audit.recorded": { + const data = event.data as { action: string; target_type: string; target_id: string }; + setAudit((current) => [ + { + id: event.id, + action: data.action, + actor_user_id: null, + target_type: data.target_type, + target_id: data.target_id, + created_at: new Date().toISOString(), + }, + ...current, + ]); + break; + } + default: + break; + } + }, + ); + const load = React.useCallback(async () => { if (!orgId) { setLoading(false); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 8f60dd9..dba5ee1 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { RealtimeProvider } from "@/lib/realtime"; import { SessionProvider } from "@/lib/session"; export const metadata: Metadata = { @@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - {children} + + {/* One socket for the whole app: every live feed rides on it. */} + {children} + ); diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 1d7d32b..0fb1342 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -8,6 +8,7 @@ import { api } from "@/lib/api"; import { CommandPalette } from "@/components/CommandPalette"; import { NAV_ITEMS } from "@/lib/nav"; import { APP_VERSION_LABEL } from "@/lib/version"; +import { useRealtime } from "@/lib/realtime"; import { useSession } from "@/lib/session"; /** @@ -38,6 +39,38 @@ function VersionBadge() { ); } +/** A tiny dot that tells the user whether the live feed is connected. */ +function LiveIndicator() { + const { status } = useRealtime(); + const tone = + status === "live" + ? "success" + : status === "connecting" || status === "reconnecting" + ? "warning" + : "neutral"; + const label = + status === "live" + ? "زنده" + : status === "connecting" + ? "در حال اتصال…" + : status === "reconnecting" + ? "در حال اتصال مجدد…" + : "آفلاین"; + return ( + + + {label} + + ); +} + /** Credit badge — reads the organization's real balance, never a placeholder. */ function CreditBadge({ organizationId }: { organizationId: string | undefined }) { const [balance, setBalance] = React.useState(null); @@ -195,6 +228,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { + + + diff --git a/apps/web/src/components/sandbox-panel.tsx b/apps/web/src/components/sandbox-panel.tsx index 1b6b599..5158d16 100644 --- a/apps/web/src/components/sandbox-panel.tsx +++ b/apps/web/src/components/sandbox-panel.tsx @@ -35,6 +35,7 @@ import { Stat, } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; +import { useLiveChannel } from "@/lib/realtime"; const STATUS_TONES = { ready: "success", @@ -316,6 +317,53 @@ function SessionPanel({ const [snapshotName, setSnapshotName] = React.useState(""); const [snapshotBusy, setSnapshotBusy] = React.useState(false); + // Live feed: command output and session lifecycle events push live. + useLiveChannel( + [`session:${session.id}`], + (event) => { + switch (event.type) { + case "sandbox.command": { + const data = event.data as { + run_id: string; + command: string; + status: string; + exit_code: number | null; + stdout: string; + stderr: string; + duration_ms: number; + truncated: boolean; + }; + setRuns((current) => { + if (current.some((r) => r.id === data.run_id)) return current; + return [ + ...current, + { + id: data.run_id, + command: data.command, + exit_code: data.exit_code, + stdout: data.stdout, + stderr: data.stderr, + duration_ms: data.duration_ms, + truncated: data.truncated, + created_at: new Date().toISOString(), + }, + ]; + }); + break; + } + case "sandbox.session": { + const data = event.data as { session_id: string; status: string }; + if (data.session_id === session.id) { + onSessionChange({ ...session, status: data.status }); + } + break; + } + default: + break; + } + }, + ); + const [previewPort, setPreviewPort] = React.useState("8080"); const [previewUrl, setPreviewUrl] = React.useState(null); diff --git a/apps/web/src/lib/realtime.tsx b/apps/web/src/lib/realtime.tsx new file mode 100644 index 0000000..d435e1b --- /dev/null +++ b/apps/web/src/lib/realtime.tsx @@ -0,0 +1,135 @@ +"use client"; + +/** + * Realtime session state. + * + * The provider owns the one socket for the whole app and exposes two hooks: + * + * * ``useRealtime()`` — the connection status (for the live indicator) and + * the subscribe primitives, for the rare component that needs raw access. + * * ``useLiveChannel(channels, onEvent)`` — what every page actually uses: + * subscribe for as long as the component is mounted or the channels change, + * and keep the callback stable so an inline arrow does not resubscribe on + * every render. + * + * The socket follows the session: it connects once a token exists and drops + * when the user logs out, so no page has to think about auth. + */ + +import * as React from "react"; +import type { WsEvent, WsEventType } from "@agy/contracts"; +import { getRealtimeClient, type RealtimeStatus } from "@/lib/ws"; +import { getStoredToken } from "@/lib/api"; + +interface RealtimeContextValue { + status: RealtimeStatus; + subscribe: (channels: string[]) => void; + unsubscribe: (channels: string[]) => void; +} + +const RealtimeContext = React.createContext(null); + +export function RealtimeProvider({ children }: { children: React.ReactNode }) { + const client = React.useMemo(() => getRealtimeClient(), []); + const [status] = useRealtimeState(client); + + // The connection follows the token: present -> live, gone -> closed. + React.useEffect(() => { + const connect = () => { + if (getStoredToken()) client.connect(); + else client.disconnect(); + }; + connect(); + // ``storage`` fires when another tab logs in or out; polling covers the + // same-tab case where the token is written and read within one tick. + const interval = setInterval(connect, 2_000); + window.addEventListener("storage", connect); + return () => { + clearInterval(interval); + window.removeEventListener("storage", connect); + client.disconnect(); + }; + }, [client]); + + const value = React.useMemo( + () => ({ + status, + subscribe: (channels) => client.subscribe(channels), + unsubscribe: (channels) => client.unsubscribe(channels), + }), + [status, client], + ); + + return {children}; +} + +function useRealtimeState(client: ReturnType): [RealtimeStatus] { + const [status, setStatus] = React.useState(() => client.getStatus()); + React.useEffect(() => client.onStatus(setStatus), [client]); + return [status]; +} + +export function useRealtime(): RealtimeContextValue { + const value = React.useContext(RealtimeContext); + // A missing provider is a wiring bug, not a runtime condition: fail loudly + // in development rather than silently showing a page that never updates. + if (!value) { + throw new Error("useRealtime must be used inside "); + } + return value; +} + +/** + * Subscribe to channels for the lifetime of the component. + * + * ``onEvent`` is kept in a ref, so the subscription is not re-established + * because the callback identity changed — the classic cause of a live feed + * that resubscribes on every render and drops events in between. + */ +export function useLiveChannel( + channels: string[], + onEvent: (event: WsEvent) => void, +): void { + const client = React.useMemo(() => getRealtimeClient(), []); + const handler = React.useRef(onEvent); + React.useEffect(() => { + handler.current = onEvent; + }, [onEvent]); + + // A stable key: the effect re-runs when the channel *set* changes, not when + // a new array with the same contents is passed. + const channelKey = channels.join("\u0000"); + + React.useEffect(() => { + const active = channelKey ? channelKey.split("\u0000") : []; + if (active.length === 0) return; + client.subscribe(active); + const off = client.onEvent((event) => handler.current(event)); + return () => { + off(); + client.unsubscribe(active); + }; + }, [client, channelKey]); +} + +/** + * Subscribe to one event type only — the common case for a page that renders + * exactly one live surface (a timeline, a terminal, a counter). + */ +export function useLiveEvent( + channels: string[], + type: WsEventType, + onEvent: (event: WsEvent) => void, +): void { + const handler = React.useRef(onEvent); + React.useEffect(() => { + handler.current = onEvent; + }, [onEvent]); + + useLiveChannel( + channels, + React.useCallback((event: WsEvent) => { + if (event.type === type) handler.current(event); + }, [type]), + ); +} diff --git a/apps/web/src/lib/ws.ts b/apps/web/src/lib/ws.ts new file mode 100644 index 0000000..3ec63ce --- /dev/null +++ b/apps/web/src/lib/ws.ts @@ -0,0 +1,270 @@ +/** + * The realtime client — one WebSocket for the whole app. + * + * Design decisions worth knowing before editing: + * + * * **One socket, shared.** Pages subscribe and unsubscribe through + * ``RealtimeProvider``; they never own a connection. A dashboard and a task + * page both being live must not mean two sockets and two auth round-trips. + * * **The token is a function, not a value.** ``getToken`` is read at connect + * time, so a login/logout cycle in another tab reconnects correctly instead + * of pushing on a dead credential. + * * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is + * how a flapping network becomes an outage. The cap is deliberately low + * enough that "live" feels live again within seconds. + * * **Heartbeat.** A silent middlebox will happily hold a dead socket open + * forever. A ping every ``HEARTBEAT_INTERVAL_MS`` plus a pong timeout moves + * that failure from "the page quietly stops updating" to "the socket + * reconnects and updates resume". + */ + +import type { WsClientFrame, WsEvent, WsServerFrame } from "@agy/contracts"; +import { getStoredToken } from "@/lib/api"; + +/** Where the socket lives: the API origin, upgraded from http(s) to ws(s). */ +export function websocketUrl(path = "/api/ws"): string { + const base = + process.env.NEXT_PUBLIC_API_URL ?? + (typeof window !== "undefined" ? window.location.origin : "http://localhost:8000"); + const url = new URL(base); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = path; + return url.toString(); +} + +export type RealtimeStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline"; + +const MIN_BACKOFF_MS = 500; +const MAX_BACKOFF_MS = 8_000; +const HEARTBEAT_INTERVAL_MS = 25_000; +/** If no pong arrives within this window the socket is considered dead. */ +const PONG_TIMEOUT_MS = 10_000; + +type EventListener = (event: WsEvent) => void; +type StatusListener = (status: RealtimeStatus) => void; + +export class RealtimeClient { + private socket: WebSocket | null = null; + private status: RealtimeStatus = "idle"; + private readonly listeners = new Set(); + private readonly statusListeners = new Set(); + private readonly subscriptions = new Set(); + private heartbeatTimer: ReturnType | null = null; + private pongTimer: ReturnType | null = null; + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private closedByUs = false; + + constructor(private readonly getToken: () => string | null = getStoredToken) {} + + // -- lifecycle ----------------------------------------------------------- + + connect(): void { + const token = this.getToken(); + if (!token) { + // No session yet: nothing to push. The provider reconnects on login. + this.setStatus("idle"); + return; + } + if ( + this.socket && + (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + + this.closedByUs = false; + this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting"); + + const socket = new WebSocket(`${websocketUrl()}?token=${encodeURIComponent(token)}`); + this.socket = socket; + + socket.onopen = () => { + this.reconnectAttempts = 0; + this.setStatus("live"); + this.startHeartbeat(); + // Re-subscribe: a new socket knows nothing about what this client cared + // about, and pages do not (and should not) re-run their effects to fix it. + if (this.subscriptions.size > 0) { + this.send({ action: "subscribe", channels: [...this.subscriptions] }); + } + }; + + socket.onmessage = (message) => this.handleFrame(message.data); + + socket.onclose = () => { + this.stopHeartbeat(); + this.socket = null; + if (this.closedByUs) return; + this.scheduleReconnect(); + }; + + socket.onerror = () => { + // A close always follows an error; the close handler does the work. + // This only stops an unhandled rejection in stricter runtimes. + }; + } + + disconnect(): void { + this.closedByUs = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.stopHeartbeat(); + this.reconnectAttempts = 0; + if (this.socket) { + this.socket.close(); + this.socket = null; + } + this.setStatus("idle"); + } + + private scheduleReconnect(): void { + this.setStatus("reconnecting"); + const delay = Math.min( + MAX_BACKOFF_MS, + MIN_BACKOFF_MS * 2 ** this.reconnectAttempts, + ); + this.reconnectAttempts += 1; + this.reconnectTimer = setTimeout(() => this.connect(), delay); + } + + // -- subscriptions --------------------------------------------------------- + + subscribe(channels: string[]): void { + const fresh = channels.filter((channel) => !this.subscriptions.has(channel)); + if (fresh.length === 0) return; + fresh.forEach((channel) => this.subscriptions.add(channel)); + this.send({ action: "subscribe", channels: fresh }); + } + + unsubscribe(channels: string[]): void { + const active = channels.filter((channel) => this.subscriptions.has(channel)); + if (active.length === 0) return; + active.forEach((channel) => this.subscriptions.delete(channel)); + this.send({ action: "unsubscribe", channels: active }); + } + + activeSubscriptions(): string[] { + return [...this.subscriptions]; + } + + // -- listeners -------------------------------------------------------------- + + onEvent(listener: EventListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onStatus(listener: StatusListener): () => void { + this.statusListeners.add(listener); + return () => this.statusListeners.delete(listener); + } + + getStatus(): RealtimeStatus { + return this.status; + } + + // -- frames ----------------------------------------------------------------- + + private send(frame: WsClientFrame): void { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(frame)); + } + // A frame sent before the socket is open is not an error: on open, the + // full subscription set is replayed anyway. + } + + private handleFrame(raw: unknown): void { + if (typeof raw !== "string") return; + let frame: WsServerFrame; + try { + frame = JSON.parse(raw) as WsServerFrame; + } catch { + return; + } + + switch (frame.type) { + case "event": + this.emitEvent(frame.event); + break; + case "pong": + this.stopPongTimer(); + break; + case "subscribed": + // A rejected channel is a server answer, not a client error: the page + // keeps its initial data and the console shows why it is not live. + if (frame.rejected.length > 0) { + console.warn("realtime: channels rejected by the server", frame.rejected); + } + break; + case "error": + console.warn("realtime: server error frame", frame.message); + break; + default: + break; + } + } + + private emitEvent(event: WsEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event); + } catch (error) { + // One broken page must not silence every other page's feed. + console.error("realtime: event listener failed", error); + } + } + } + + private setStatus(status: RealtimeStatus): void { + if (this.status === status) return; + this.status = status; + for (const listener of [...this.statusListeners]) { + try { + listener(status); + } catch (error) { + console.error("realtime: status listener failed", error); + } + } + } + + // -- heartbeat ----------------------------------------------------------------- + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + this.send({ action: "ping" }); + this.pongTimer = setTimeout(() => { + // No pong: the socket is a zombie. Close it so the reconnect path runs. + this.socket?.close(); + }, PONG_TIMEOUT_MS); + }, HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + this.stopPongTimer(); + } + + private stopPongTimer(): void { + if (this.pongTimer) { + clearTimeout(this.pongTimer); + this.pongTimer = null; + } + } +} + +let sharedClient: RealtimeClient | null = null; + +/** The process-wide client the provider owns. */ +export function getRealtimeClient(): RealtimeClient { + if (!sharedClient) { + sharedClient = new RealtimeClient(); + } + return sharedClient; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fdad3a0..ec837f3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -666,3 +666,86 @@ export interface ErrorQuery { limit?: number; offset?: number; } + +// --------------------------------------------------------------------------- +// Realtime (WebSocket) +// +// The live feed is the same product surface as the REST API: one envelope +// shape, mirrored key-for-key from ``app/application/ports/realtime.py``. +// A page that switches on ``WsEventType`` never needs to know which part of +// the backend produced the event. +// +// Channel naming is what a page subscribes to — ``org:{id}`` for the tenant +// feed, ``task:{id}`` for one task, and so on. The server authorizes every +// subscribe against the caller's memberships, so a channel the user may not +// see simply comes back rejected. +// --------------------------------------------------------------------------- + +export type WsEventType = + | "run.step" + | "run.status" + | "task.status" + | "task.created" + | "chat.pending" + | "message.created" + | "approval.pending" + | "artifact.created" + | "sandbox.command" + | "sandbox.session" + | "sandbox.reaped" + | "usage.recorded" + | "billing.wallet" + | "error.recorded" + | "audit.recorded" + | "worker.status"; + +/** One pushed event, as the browser receives it. */ +export interface WsEvent> { + id: string; + type: WsEventType; + data: T; + organization_id: string | null; + /** ISO-8601 UTC; render with ``toLocaleString`` like every other timestamp. */ + at: string; +} + +/** Every frame the server sends. Control frames and events share ``type``. */ +export type WsServerFrame = + | { type: "hello"; user_id: string; server_time: string } + | { type: "subscribed"; accepted: string[]; rejected: string[] } + | { type: "unsubscribed"; channels: string[] } + | { type: "pong" } + | { type: "error"; message: string } + | { type: "event"; event: WsEvent }; + +/** Every frame the client may send. */ +export type WsClientFrame = + | { action: "subscribe"; channels: string[] } + | { action: "unsubscribe"; channels: string[] } + | { action: "ping" }; + +/** Live run-step payload — what the task timeline renders as it arrives. */ +export interface WsRunStepData { + run_id: string; + task_id: string; + step: { + id: string; + index: number; + kind: string; + title: string; + payload: Record; + }; +} + +/** Live sandbox command payload — the terminal panel's stream. */ +export interface WsSandboxCommandData { + session_id: string; + run_id: string; + command: string; + status: string; + exit_code: number | null; + stdout: string; + stderr: string; + duration_ms: number; + truncated: boolean; +} From 8066e9d0556ecea97ca94ca086e6cbc5cc9b9016 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 13:54:56 +0330 Subject: [PATCH 06/12] feat: remove public REST API; route all app calls over WebSocket RPC The platform no longer exposes a public /api/* REST surface. The original HTTP route handlers are preserved verbatim as the single source of truth inside an internal, never-socket-bound FastAPI app (rpc_app), and are now reached only via RPC-over-WebSocket dispatch. - realtime router: anonymous connections allowed only to /api/auth/login and /api/auth/signup; all other RPCs/subscribes refused until token auth. - RPC dispatch re-injects the bearer token and X-Request-ID, then forwards through httpx.ASGITransport against the internal app (no network hop). - Public app now serves only /, /health, /health/ready and /api/ws; docs/openapi disabled. - Frontend: apiRequest routes /api/* to ws.rpc(); /health probes keep fetch. RealtimeClient gains rpc() with id correlation, timeout and AbortSignal. - Tests repointed to get_rpc_app(); anonymous-auth behaviour covered. Result: zero REST on the public surface; 264 backend tests + frontend typecheck pass. --- apps/api/app/interfaces/realtime/manager.py | 7 +- apps/api/app/interfaces/realtime/router.py | 201 +++++++++++++--- apps/api/app/interfaces/realtime/rpc_app.py | 79 ++++++ apps/api/app/main.py | 45 ++-- apps/api/tests/conftest.py | 6 +- apps/api/tests/test_error_log.py | 6 +- apps/api/tests/test_realtime.py | 50 +++- apps/web/src/lib/api.ts | 31 ++- apps/web/src/lib/realtime.tsx | 11 +- apps/web/src/lib/ws.ts | 251 ++++++++++++++++---- apps/web/src/test/api.test.ts | 147 +++++------- packages/contracts/src/index.ts | 19 +- 12 files changed, 631 insertions(+), 222 deletions(-) create mode 100644 apps/api/app/interfaces/realtime/rpc_app.py diff --git a/apps/api/app/interfaces/realtime/manager.py b/apps/api/app/interfaces/realtime/manager.py index fb9fc54..0758943 100644 --- a/apps/api/app/interfaces/realtime/manager.py +++ b/apps/api/app/interfaces/realtime/manager.py @@ -48,11 +48,14 @@ ChannelAuthorizer = Callable[["Connection", str], Awaitable[bool]] #: both its connection set and its channel routing table. @dataclass(slots=True, eq=False) class Connection: - """One authenticated browser socket.""" + """One browser socket. ``user_id`` is ``None`` for an anonymous (pre-login) + connection: it may only call the bootstrap RPCs (login/signup) and may not + subscribe to live channels.""" - user_id: str + user_id: str | None is_platform_owner: bool websocket: WebSocket + token: str | None = None channels: set[str] = field(default_factory=set) _queue: asyncio.Queue[str] = field( default_factory=lambda: asyncio.Queue(maxsize=MAX_QUEUE_DEPTH) diff --git a/apps/api/app/interfaces/realtime/router.py b/apps/api/app/interfaces/realtime/router.py index 6a4d91b..25d9256 100644 --- a/apps/api/app/interfaces/realtime/router.py +++ b/apps/api/app/interfaces/realtime/router.py @@ -1,39 +1,48 @@ -"""The WebSocket endpoint — one socket per browser, every live feed on it. +"""The WebSocket endpoint — one socket per browser, every live feed and every +RPC on it. ``GET /api/ws?token=`` The token travels as a query parameter because the browser WebSocket API cannot -set an ``Authorization`` header. That is a deliberate, contained exception to -the header-only rule: the token is short-lived, the socket is TLS-terminated in -any real deployment, and the alternative (a cookie) would drag CSRF into a -protocol that has no CSRF story. +set an ``Authorization`` header. That is a deliberate, contained exception to the +header-only rule: the token is short-lived, the socket is TLS-terminated in any +real deployment, and the alternative (a cookie) would drag CSRF into a protocol +that has no CSRF story. -Protocol — small, symmetric, JSON in both directions: +Protocol — small, symmetric, JSON in both directions. Server->client frames +share the ``type`` discriminator; client->server frames use ``action``:: -.. code-block:: text - - client -> {"action": "subscribe", "channels": ["org:…", "task:…"]} + client -> {"action": "subscribe", "channels": ["org:…"]} client -> {"action": "unsubscribe", "channels": ["task:…"]} client -> {"action": "ping"} - server -> {"type": "hello", "user_id": "…"} - server -> {"type": "subscribed", "accepted": [...], "rejected": [...]} - server -> {"type": "event", "event": {…envelope…}} + client -> {"action": "rpc", "id": "…", "method": "POST", + "path": "/api/organizations", "body": {"name": "…"}} + server -> {"type": "hello", "user_id": "…"|null} + server -> {"type": "subscribed", "accepted": [...], "rejected": [...]} + server -> {"type": "event", "event": {…envelope…}} + server -> {"type": "rpc_result", "id": "…", "status": 200, "body": …} server -> {"type": "pong"} - server -> {"type": "error", "message": "…"} + server -> {"type": "error", "message": "…"} -A rejected channel is reported, not hidden: "my task page is not live" must be -answerable from the browser console alone. +A connection may be **anonymous** (no token): that is the bootstrap state that +lets a brand-new visitor call ``/api/auth/login`` and ``/api/auth/signup`` over +RPC. Every other RPC and every ``subscribe`` requires an authenticated +connection; the token presented at connect time is re-injected as a bearer +header when the RPC is dispatched into the internal app, so the handlers' own +auth dependencies still run unchanged. -Authorization queries open their own short-lived session: a socket can stay -open for hours, and one SQLAlchemy session per open tab would quietly exhaust -the connection pool. +Authorization queries open their own short-lived session: a socket can stay open +for hours, and one SQLAlchemy session per open tab would quietly exhaust the +connection pool. """ from __future__ import annotations +import asyncio import json import logging +import httpx from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from app.application.ports.realtime import dedupe_channels @@ -58,6 +67,27 @@ def realtime_manager() -> ConnectionManager: return _manager +#: Paths an anonymous (not-yet-logged-in) connection is allowed to call. These are +#: the only two calls a visitor can make; everything else needs a session. +_ANONYMOUS_RPC_PATHS = frozenset({"/api/auth/login", "/api/auth/signup"}) + +#: One in-process HTTP client that dispatches RPCs into the internal app. Built +#: lazily so importing this module never pulls the app into existence. +_rpc_client: httpx.AsyncClient | None = None + + +def _get_rpc_client() -> httpx.AsyncClient: + global _rpc_client + if _rpc_client is None: + # The internal app holds every HTTP handler; it is never socket-bound, so + # it is reached here via an in-process ASGI transport instead of the wire. + from app.interfaces.realtime.rpc_app import get_rpc_app + + transport = httpx.ASGITransport(app=get_rpc_app()) + _rpc_client = httpx.AsyncClient(transport=transport, base_url="http://internal") + return _rpc_client + + @router.websocket("/ws") async def realtime_socket( websocket: WebSocket, @@ -69,15 +99,15 @@ async def realtime_socket( await _manager.register(connection) - await _send( - websocket, + _send_frame( + connection, {"type": "hello", "user_id": connection.user_id, "server_time": _now_iso()}, ) try: while True: raw = await websocket.receive_text() - await _handle_message(websocket, connection, raw) + await _handle_message(connection, raw) except WebSocketDisconnect: pass except Exception: # noqa: BLE001 - any read failure ends the socket cleanly @@ -87,8 +117,16 @@ async def realtime_socket( async def _authenticate(websocket: WebSocket, token: str | None) -> Connection | None: - """Accept only a valid, active user. Closes 1008 otherwise.""" + """Accept an anonymous or an authenticated, active user. + + * No token -> an anonymous connection (bootstrap for login/signup). + * A token that decodes to a real, active user -> an authenticated connection + whose raw token is kept for RPC re-injection. + * A presented-but-invalid token -> 1008, so the client can tell "bad token" + from "network dropped" and stop retrying. + """ user = None + raw_token: str | None = None if token: try: payload = decode_access_token(token) @@ -97,35 +135,48 @@ async def _authenticate(websocket: WebSocket, token: str | None) -> Connection | # Own session: the socket must not depend on request scope. async with SessionFactory() as session: user = await UserRepository(session).get(str(user_id)) + if user is not None and user.is_active: + raw_token = token except AuthenticationError: user = None - if user is None or not user.is_active: - # 1008 (policy violation) rather than a bare close: the client's - # reconnect logic can then tell "bad token" from "network dropped". + + if token and user is None: await websocket.close(code=1008, reason="unauthorized") return None + await websocket.accept() return Connection( - user_id=user.id, - is_platform_owner=user.is_platform_owner, + user_id=user.id if user else None, + is_platform_owner=user.is_platform_owner if user else False, + token=raw_token, websocket=websocket, ) -async def _handle_message(websocket: WebSocket, connection: Connection, raw: str) -> None: +async def _handle_message(connection: Connection, raw: str) -> None: try: message = json.loads(raw) except (TypeError, ValueError): - await _send(websocket, {"type": "error", "message": "malformed JSON frame"}) + _send_frame(connection, {"type": "error", "message": "malformed JSON frame"}) return if not isinstance(message, dict): - await _send(websocket, {"type": "error", "message": "frame must be an object"}) + _send_frame(connection, {"type": "error", "message": "frame must be an object"}) return action = message.get("action") if action == "ping": - await _send(websocket, {"type": "pong"}) + _send_frame(connection, {"type": "pong"}) + return + + if action == "rpc": + # Run off the receive loop: a slow handler must not stall pings. + asyncio.create_task(_handle_rpc(connection, message)) + return + + # subscribe / unsubscribe require a logged-in user. + if connection.user_id is None: + _send_frame(connection, {"type": "error", "message": "authentication required"}) return channels = dedupe_channels( @@ -134,17 +185,85 @@ async def _handle_message(websocket: WebSocket, connection: Connection, raw: str if action == "subscribe": accepted, rejected = await _subscribe(connection, channels) - await _send( - websocket, {"type": "subscribed", "accepted": accepted, "rejected": rejected} + _send_frame( + connection, {"type": "subscribed", "accepted": accepted, "rejected": rejected} ) return if action == "unsubscribe": await _manager.unsubscribe(connection, list(channels)) - await _send(websocket, {"type": "unsubscribed", "channels": channels}) + _send_frame(connection, {"type": "unsubscribed", "channels": channels}) return - await _send(websocket, {"type": "error", "message": f"unknown action: {action!r}"}) + _send_frame(connection, {"type": "error", "message": f"unknown action: {action!r}"}) + + +async def _handle_rpc(connection: Connection, message: dict) -> None: + """Dispatch one client RPC into the internal app and return the result.""" + req_id = message.get("id") + if not req_id: + _send_frame(connection, {"type": "error", "message": "rpc frame needs an id"}) + return + + method = str(message.get("method") or "GET").upper() + path = message.get("path") + if not isinstance(path, str) or not path: + _send_rpc_result( + connection, + req_id, + 400, + {"error": {"code": "bad_request", "message": "rpc frame needs a path"}}, + ) + return + + # Gate unauthenticated callers to login/signup only. + if connection.user_id is None and path not in _ANONYMOUS_RPC_PATHS: + _send_rpc_result( + connection, + req_id, + 401, + {"error": {"code": "unauthenticated", "message": "نیاز به ورود دارید."}}, + ) + return + + body = message.get("body") + headers = {"X-Request-ID": str(req_id), "User-Agent": "realtime-rpc"} + if connection.token: + headers["Authorization"] = f"Bearer {connection.token}" + + try: + client = _get_rpc_client() + if isinstance(body, (dict, list)): + response = await client.request(method, path, json=body, headers=headers) + elif body is None: + response = await client.request(method, path, headers=headers) + else: + response = await client.request(method, path, content=str(body), headers=headers) + except Exception: # noqa: BLE001 - reported to the caller as a 500 envelope + logger.exception("realtime: rpc dispatch to internal app failed") + _send_rpc_result( + connection, + req_id, + 500, + {"error": {"code": "internal_error", "message": "خطای داخلی سرور."}}, + ) + return + + status = response.status_code + try: + payload = response.json() + except Exception: + payload = None + _send_rpc_result(connection, req_id, status, payload) + + +def _send_rpc_result( + connection: Connection, req_id: object, status: int, body: object +) -> None: + _send_frame( + connection, + {"type": "rpc_result", "id": req_id, "status": status, "body": body}, + ) async def _subscribe( @@ -157,7 +276,7 @@ async def _subscribe( async with SessionFactory() as session: return await authorize_channel( session, - user_id=conn.user_id, + user_id=conn.user_id or "", is_platform_owner=conn.is_platform_owner, channel=channel, ) @@ -171,5 +290,11 @@ def _now_iso() -> str: return utcnow().isoformat() -async def _send(websocket: WebSocket, payload: dict) -> None: - await websocket.send_text(json.dumps(payload, ensure_ascii=False, default=str)) +def _send_frame(connection: Connection, payload: dict) -> None: + """Queue a frame on the connection's single-writer outbox. + + Every frame — control, event and rpc_result — goes through the same queue so + a slow RPC task can never interleave bytes with the receive loop's own + frames and corrupt the stream. + """ + connection.offer(json.dumps(payload, ensure_ascii=False, default=str)) diff --git a/apps/api/app/interfaces/realtime/rpc_app.py b/apps/api/app/interfaces/realtime/rpc_app.py new file mode 100644 index 0000000..11b8ae2 --- /dev/null +++ b/apps/api/app/interfaces/realtime/rpc_app.py @@ -0,0 +1,79 @@ +"""Internal, never-socket-bound FastAPI app that holds every HTTP handler. + +The public app (``app.main.create_app``) exposes only ``/health`` and the +``/api/ws`` WebSocket. All request/response logic lives here and is reached in +exactly one of two ways: + +* from the WS-RPC proxy (``app.interfaces.realtime.router``), which dispatches + client RPCs into this app over an in-process ASGI transport, and +* from the test-suite's ``AsyncClient``/``TestClient``. + +It is intentionally never bound to a socket, so including the routers here adds no +public REST surface — the only public routes are the ones ``create_app`` +registers. Keeping the handlers in one FastAPI app also means they run through the +real middleware, dependencies, validation and error handling, unchanged. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from app.core.config import settings +from app.interfaces.http.errors import register_exception_handlers +from app.interfaces.http.request_context import RequestContextMiddleware +from app.interfaces.http.routers import ( + admin, + agy, + auth, + billing, + health, + knowledge, + organizations, + projects, + sandbox, + workforce, +) +from app.interfaces.realtime.router import router as realtime_router +from app.main import lifespan # shared lifespan installs the realtime bus + +#: Module-level singleton so the RPC proxy and the tests share one app instance. +_rpc_app: FastAPI | None = None + + +def build_rpc_app() -> FastAPI: + """Construct the full handler app. Called once per process.""" + application = FastAPI( + title=settings.app_name, + version="0.1.0", + description="AI Workforce Platform — internal RPC dispatch surface.", + lifespan=lifespan, + # This app is reached in-process, never over the network, so the + # interactive docs would only be a liability. + docs_url=None, + openapi_url=None, + ) + application.add_middleware(RequestContextMiddleware) + register_exception_handlers(application) + + # Same mounting as the old public app: health at the root, everything else + # under /api, and the live socket alongside the RPC surface. + 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.include_router(agy.router, prefix="/api") + application.include_router(realtime_router, prefix="/api") + return application + + +def get_rpc_app() -> FastAPI: + """Return the process-wide internal app, building it on first use.""" + global _rpc_app + if _rpc_app is None: + _rpc_app = build_rpc_app() + return _rpc_app diff --git a/apps/api/app/main.py b/apps/api/app/main.py index ba4bc41..91ccade 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -15,18 +15,7 @@ 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, - agy, - auth, - billing, - health, - knowledge, - organizations, - projects, - sandbox, - workforce, -) +from app.interfaces.http.routers import health from app.interfaces.realtime.bus import install as install_realtime_bus from app.interfaces.realtime.bus import start_relay, stop_relay from app.interfaces.realtime.router import realtime_manager @@ -99,13 +88,22 @@ async def _record_startup_failure(exc: BaseException) -> None: def create_app() -> FastAPI: + """Build the *public* app. + + This is the only app uvicorn binds. It deliberately exposes no REST API: the + application surface is reachable only over the ``/api/ws`` WebSocket (live + feeds + RPC). ``/health`` stays as an HTTP liveness probe because deploy + orchestration and the browser's status check both need a plain GET, and a + probe is not an application endpoint. + """ 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", + # No REST API on the public surface, so the interactive docs are off. + docs_url=None, + openapi_url=None, ) # Starlette applies middleware outermost-first in reverse registration @@ -128,17 +126,10 @@ def create_app() -> FastAPI: register_exception_handlers(application) + # The only public routes: a liveness probe and the single WebSocket that + # carries every application call (live events + RPC). All HTTP handlers live + # in the internal app reached through that socket. 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.include_router(agy.router, prefix="/api") - # The live feed: one WebSocket per browser, every push on it. application.include_router(realtime_router, prefix="/api") @application.get("/", tags=["meta"]) @@ -146,8 +137,12 @@ def create_app() -> FastAPI: return { "name": settings.app_name, "version": "0.1.0", - "docs": "/docs", "health": "/health", + "websocket": "/api/ws", + "note": ( + "All application calls travel over the WebSocket at /api/ws " + "(live feeds + RPC). There is no REST API on this server." + ), } return application diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index 358471f..4df513e 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -24,7 +24,11 @@ from app.core.config import settings from app.core.database import Base, get_session from app.domain.base import new_id from app.infrastructure.db.models import UserRow -from app.main import app +# The internal "full" app holds every HTTP handler; the public app (app.main.app) +# now serves only /health + /api/ws, so REST tests must target the full app. +from app.interfaces.realtime.rpc_app import get_rpc_app + +app = get_rpc_app() @pytest.fixture(scope="session", autouse=True) diff --git a/apps/api/tests/test_error_log.py b/apps/api/tests/test_error_log.py index 6fab4a1..de0d221 100644 --- a/apps/api/tests/test_error_log.py +++ b/apps/api/tests/test_error_log.py @@ -22,7 +22,11 @@ from app.core.errors import ProviderUnavailableError from app.infrastructure.db.models import ErrorEventRow from app.interfaces.http.deps import OrgContext, get_org_context from app.interfaces.http.request_context import REQUEST_ID_HEADER -from app.main import app +# The internal "full" app holds every HTTP handler; the public app serves only +# /health + /api/ws, so this module's REST exercises must target the full app. +from app.interfaces.realtime.rpc_app import get_rpc_app + +app = get_rpc_app() from tests.conftest import auth_header, signup #: The password in this DSN must never reach the database. diff --git a/apps/api/tests/test_realtime.py b/apps/api/tests/test_realtime.py index aaa19a9..3907a93 100644 --- a/apps/api/tests/test_realtime.py +++ b/apps/api/tests/test_realtime.py @@ -32,7 +32,11 @@ from app.infrastructure.db.models import UserRow from app.interfaces.realtime.authz import authorize_channel from app.interfaces.realtime.bus import get_bus from app.interfaces.realtime.manager import Connection, ConnectionManager -from app.main import app +# The internal "full" app holds every HTTP handler; the public app serves only +# /health + /api/ws. The socket and the RPC surface both live on the full app. +from app.interfaces.realtime.rpc_app import get_rpc_app + +app = get_rpc_app() API_ROOT = Path(__file__).resolve().parents[1] @@ -331,10 +335,46 @@ def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict: raise AssertionError(f"no {wanted_type} event arrived") -def test_socket_rejects_a_missing_token(ws_client): - with pytest.raises(Exception), ws_client.websocket_connect("/api/ws"): # noqa: B017, SIM117 - # No token: the endpoint closes with 1008 before accepting. - pass +def test_socket_allows_anonymous_then_restricts_actions(ws_client): + """No token opens an anonymous socket, but only login/signup may be called. + + A visitor who has not logged in can reach ``/api/auth/login`` and + ``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused. + """ + with ws_client.websocket_connect("/api/ws") as socket: + hello = socket.receive_json() + assert hello["type"] == "hello" + assert hello["user_id"] is None + + # A protected RPC is refused for an anonymous connection. + socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"}) + result = socket.receive_json() + assert result["type"] == "rpc_result" + assert result["status"] == 401 + + # Subscribing is refused until the caller logs in. + socket.send_json({"action": "subscribe", "channels": ["org:x"]}) + refused = socket.receive_json() + assert refused["type"] == "error" + + +def test_anonymous_may_call_login_over_rpc(ws_client): + with ws_client.websocket_connect("/api/ws") as socket: + socket.receive_json() # hello + socket.send_json( + { + "action": "rpc", + "id": "login-1", + "method": "POST", + "path": "/api/auth/login", + "body": {"email": "anon@example.com", "password": "password123"}, + } + ) + result = socket.receive_json() + assert result["type"] == "rpc_result" + # No such user -> the handler's own auth dependency returns 401, proving + # the RPC reached the real login route through the internal app. + assert result["status"] == 401 def test_task_creation_is_pushed_live(ws_client): diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index dc22973..2f39628 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -7,6 +7,7 @@ */ import type { ApiErrorBody } from "@agy/contracts"; +import { getRealtimeClient } from "@/lib/ws"; /** * Resolves the API base URL. @@ -89,16 +90,34 @@ export interface RequestOptions { } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { - const { method = "GET", body, token, signal } = options; + const { method = "GET", body, signal } = options; + + // Liveness and readiness probes stay on plain HTTP: they answer during a DB + // outage and are used by deploy orchestration (and the browser status check), + // so they must not depend on the authenticated socket. Every application call + // travels over the WebSocket as an RPC instead (see lib/ws.ts) — one + // connection for both live feeds and request/response. + if (path === "/health" || path === "/health/ready") { + return fetchHealth(path, method, body, signal); + } + + const client = getRealtimeClient(); + return client.rpc(method, path, body === undefined ? null : body, { signal }); +} + +/** Plain-HTTP implementation kept only for the two health probes. */ +async function fetchHealth( + path: string, + method: string, + body: unknown, + signal?: AbortSignal, +): Promise { const headers: Record = { Accept: "application/json" }; if (body !== undefined) headers["Content-Type"] = "application/json"; - const authToken = token ?? getStoredToken(); - if (authToken) headers.Authorization = `Bearer ${authToken}`; - const baseUrl = getApiBaseUrl(); let response: Response; try { - response = await fetch(`${baseUrl}${path}`, { + response = await fetch(`${getApiBaseUrl()}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), @@ -118,8 +137,6 @@ export async function apiRequest(path: string, options: RequestOptions = {}): const envelope = payload as ApiErrorBody | null; const code = envelope?.error?.code ?? "http_error"; const message = envelope?.error?.message ?? `خطای سرور (${response.status})`; - // Fall back to the header: a failure raised outside the error handlers - // (a CORS rejection, a proxy error) still carries the id. const requestId = envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined; throw new ApiError(response.status, code, message, envelope?.error?.details, requestId); diff --git a/apps/web/src/lib/realtime.tsx b/apps/web/src/lib/realtime.tsx index d435e1b..b54977d 100644 --- a/apps/web/src/lib/realtime.tsx +++ b/apps/web/src/lib/realtime.tsx @@ -19,7 +19,6 @@ import * as React from "react"; import type { WsEvent, WsEventType } from "@agy/contracts"; import { getRealtimeClient, type RealtimeStatus } from "@/lib/ws"; -import { getStoredToken } from "@/lib/api"; interface RealtimeContextValue { status: RealtimeStatus; @@ -33,12 +32,12 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { const client = React.useMemo(() => getRealtimeClient(), []); const [status] = useRealtimeState(client); - // The connection follows the token: present -> live, gone -> closed. + // The connection follows the session: one socket for the app, opened even + // before login (anonymous) so the first ``login``/``signup`` RPC can ride it. + // ``client.connect()`` is a no-op when the socket already matches the current + // token, and reconnects (anonymous <-> authenticated) when the token changes. React.useEffect(() => { - const connect = () => { - if (getStoredToken()) client.connect(); - else client.disconnect(); - }; + const connect = () => client.connect(); connect(); // ``storage`` fires when another tab logs in or out; polling covers the // same-tab case where the token is written and read within one tick. diff --git a/apps/web/src/lib/ws.ts b/apps/web/src/lib/ws.ts index 3ec63ce..3645b0c 100644 --- a/apps/web/src/lib/ws.ts +++ b/apps/web/src/lib/ws.ts @@ -9,15 +9,21 @@ * * **The token is a function, not a value.** ``getToken`` is read at connect * time, so a login/logout cycle in another tab reconnects correctly instead * of pushing on a dead credential. - * * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is - * how a flapping network becomes an outage. The cap is deliberately low - * enough that "live" feels live again within seconds. + * * **Anonymous before login.** The socket opens without a token so a visitor + * can call ``login``/``signup`` over RPC. Once a token exists the socket is + * re-established (still the one socket) carrying the bearer token, and every + * later RPC rides that authenticated connection. + * * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is how + * a flapping network becomes an outage. The cap is deliberately low enough + * that "live" feels live again within seconds. * * **Heartbeat.** A silent middlebox will happily hold a dead socket open * forever. A ping every ``HEARTBEAT_INTERVAL_MS`` plus a pong timeout moves * that failure from "the page quietly stops updating" to "the socket * reconnects and updates resume". */ +import type { ApiErrorBody } from "@agy/contracts"; +import { ApiError } from "@/lib/api"; import type { WsClientFrame, WsEvent, WsServerFrame } from "@agy/contracts"; import { getStoredToken } from "@/lib/api"; @@ -39,10 +45,19 @@ const MAX_BACKOFF_MS = 8_000; const HEARTBEAT_INTERVAL_MS = 25_000; /** If no pong arrives within this window the socket is considered dead. */ const PONG_TIMEOUT_MS = 10_000; +/** Upper bound on a single RPC before it is failed locally. Long enough for a + * slow DB call, short enough that the UI does not hang forever. */ +const RPC_TIMEOUT_MS = 60_000; type EventListener = (event: WsEvent) => void; type StatusListener = (status: RealtimeStatus) => void; +interface PendingRpc { + resolve: (value: unknown) => void; + reject: (reason: ApiError) => void; + timer: ReturnType; +} + export class RealtimeClient { private socket: WebSocket | null = null; private status: RealtimeStatus = "idle"; @@ -54,55 +69,31 @@ export class RealtimeClient { private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; private closedByUs = false; + /** The token the current socket authenticated with; null for anonymous. */ + private connectedToken: string | null = null; + /** Resolved when the socket transitions to OPEN for the current attempt. */ + private openPromise: Promise | null = null; + /** In-flight RPCs keyed by their client-generated id. */ + private readonly pendingRpc = new Map(); + private rpcSeq = 0; constructor(private readonly getToken: () => string | null = getStoredToken) {} // -- lifecycle ----------------------------------------------------------- + /** Open (or reopen) the socket for the token the getter currently returns. */ connect(): void { const token = this.getToken(); - if (!token) { - // No session yet: nothing to push. The provider reconnects on login. - this.setStatus("idle"); - return; - } + // Already open for this exact token: nothing to do. if ( this.socket && - (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) + (this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING) && + this.connectedToken === token ) { return; } - - this.closedByUs = false; - this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting"); - - const socket = new WebSocket(`${websocketUrl()}?token=${encodeURIComponent(token)}`); - this.socket = socket; - - socket.onopen = () => { - this.reconnectAttempts = 0; - this.setStatus("live"); - this.startHeartbeat(); - // Re-subscribe: a new socket knows nothing about what this client cared - // about, and pages do not (and should not) re-run their effects to fix it. - if (this.subscriptions.size > 0) { - this.send({ action: "subscribe", channels: [...this.subscriptions] }); - } - }; - - socket.onmessage = (message) => this.handleFrame(message.data); - - socket.onclose = () => { - this.stopHeartbeat(); - this.socket = null; - if (this.closedByUs) return; - this.scheduleReconnect(); - }; - - socket.onerror = () => { - // A close always follows an error; the close handler does the work. - // This only stops an unhandled rejection in stricter runtimes. - }; + this.openWith(token); } disconnect(): void { @@ -112,14 +103,72 @@ export class RealtimeClient { this.reconnectTimer = null; } this.stopHeartbeat(); + this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد.")); this.reconnectAttempts = 0; if (this.socket) { - this.socket.close(); + try { + this.socket.close(); + } catch { + /* already gone */ + } this.socket = null; } + this.connectedToken = null; this.setStatus("idle"); } + private openWith(token: string | null): void { + this.closedByUs = false; + this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting"); + + // Replace any in-flight socket (e.g. a token change mid-connect). + if (this.socket) { + try { + this.socket.close(); + } catch { + /* ignore */ + } + this.socket = null; + } + + const url = token + ? `${websocketUrl()}?token=${encodeURIComponent(token)}` + : websocketUrl(); + const socket = new WebSocket(url); + this.socket = socket; + + this.openPromise = new Promise((resolve, reject) => { + let settled = false; + socket.onopen = () => { + settled = true; + this.connectedToken = token; + this.reconnectAttempts = 0; + this.setStatus("live"); + this.startHeartbeat(); + // Re-play subscriptions: a new socket knows nothing about what this + // client cared about, and pages do not (and should not) re-run their + // effects to fix it. + if (this.subscriptions.size > 0) { + this.send({ action: "subscribe", channels: [...this.subscriptions] }); + } + resolve(); + }; + socket.onmessage = (message) => this.handleFrame(message.data); + socket.onclose = () => { + if (!settled) { + reject( + new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."), + ); + } + this.onSocketClosed(); + }; + socket.onerror = () => { + // A close always follows an error; the close handler does the work. + // This only stops an unhandled rejection in stricter runtimes. + }; + }); + } + private scheduleReconnect(): void { this.setStatus("reconnecting"); const delay = Math.min( @@ -130,6 +179,77 @@ export class RealtimeClient { this.reconnectTimer = setTimeout(() => this.connect(), delay); } + // -- RPC ----------------------------------------------------------------- + + /** + * Call an application endpoint over the socket. + * + * Returns the parsed response body on 2xx, and rejects with an ``ApiError`` + * (status + server error envelope) on any non-2xx — identical to the old + * ``fetch``-based client, so every ``api.*`` method is unchanged. + */ + rpc( + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", + path: string, + body: unknown, + options: { signal?: AbortSignal } = {}, + ): Promise { + return this.whenOpen().then( + () => + new Promise((resolve, reject) => { + const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`; + const timer = setTimeout(() => { + this.pendingRpc.delete(id); + reject( + new ApiError( + 0, + "timeout", + "درخواست از طریق وب‌سوکت زمان‌بر شد.", + ), + ); + }, RPC_TIMEOUT_MS); + this.pendingRpc.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timer, + }); + this.send({ action: "rpc", id, method, path, body }); + const signal = options.signal; + if (signal) { + signal.addEventListener( + "abort", + () => { + const pending = this.pendingRpc.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRpc.delete(id); + pending.reject( + new ApiError(0, "aborted", "درخواست لغو شد."), + ); + } + }, + { once: true }, + ); + } + }), + ); + } + + /** Resolve once the socket is OPEN for the current token (opening if needed). */ + private whenOpen(): Promise { + const token = this.getToken(); + if ( + this.socket && + (this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING) && + this.connectedToken === token + ) { + return this.openPromise ?? Promise.resolve(); + } + this.openWith(token); + return this.openPromise ?? Promise.resolve(); + } + // -- subscriptions --------------------------------------------------------- subscribe(channels: string[]): void { @@ -173,7 +293,7 @@ export class RealtimeClient { this.socket.send(JSON.stringify(frame)); } // A frame sent before the socket is open is not an error: on open, the - // full subscription set is replayed anyway. + // full subscription set is replayed anyway, and RPCs await whenOpen(). } private handleFrame(raw: unknown): void { @@ -202,11 +322,38 @@ export class RealtimeClient { case "error": console.warn("realtime: server error frame", frame.message); break; + case "rpc_result": + this.resolveRpc(frame); + break; default: break; } } + private resolveRpc(frame: Extract): void { + const pending = this.pendingRpc.get(frame.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingRpc.delete(frame.id); + + if (frame.status >= 200 && frame.status < 300) { + pending.resolve(frame.body); + return; + } + const envelope = frame.body as ApiErrorBody | null; + const code = envelope?.error?.code ?? "http_error"; + const message = envelope?.error?.message ?? `خطای سرور (${frame.status})`; + pending.reject( + new ApiError( + frame.status, + code, + message, + envelope?.error?.details, + envelope?.error?.request_id, + ), + ); + } + private emitEvent(event: WsEvent): void { for (const listener of [...this.listeners]) { try { @@ -218,6 +365,18 @@ export class RealtimeClient { } } + private onSocketClosed(): void { + this.stopHeartbeat(); + this.socket = null; + this.connectedToken = null; + this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد.")); + if (this.closedByUs) { + this.setStatus("idle"); + return; + } + this.scheduleReconnect(); + } + private setStatus(status: RealtimeStatus): void { if (this.status === status) return; this.status = status; @@ -230,6 +389,14 @@ export class RealtimeClient { } } + private rejectPending(error: ApiError): void { + for (const pending of this.pendingRpc.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pendingRpc.clear(); + } + // -- heartbeat ----------------------------------------------------------------- private startHeartbeat(): void { diff --git a/apps/web/src/test/api.test.ts b/apps/web/src/test/api.test.ts index ded234f..47532e7 100644 --- a/apps/web/src/test/api.test.ts +++ b/apps/web/src/test/api.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ApiError, api, apiRequest, getStoredToken, setStoredToken } from "@/lib/api"; +import { getRealtimeClient } from "@/lib/ws"; describe("token storage", () => { it("round-trips a token through localStorage", () => { @@ -25,22 +26,14 @@ describe("ApiError", () => { }); }); -describe("error correlation", () => { +describe("error correlation over rpc", () => { afterEach(() => { - vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("reads the request id out of the error envelope", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - new Response( - JSON.stringify({ - error: { code: "internal_error", message: "خطا", request_id: "req-from-body" }, - }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ), - ), + vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue( + new ApiError(500, "internal_error", "خطا", undefined, "req-from-body"), ); await expect(apiRequest("/api/whatever")).rejects.toMatchObject({ @@ -49,127 +42,95 @@ describe("error correlation", () => { }); }); - it("falls back to the X-Request-ID header", async () => { - // A failure raised outside the error handlers — a CORS rejection or a proxy - // error — still carries the header, and the user should still get an id. - vi.stubGlobal( - "fetch", - vi.fn(async () => - new Response(JSON.stringify({ detail: "Disallowed CORS origin" }), { - status: 400, - headers: { "X-Request-ID": "req-from-header" }, - }), - ), + it("rejects an unauthenticated call with the server's 401", async () => { + vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue( + new ApiError(401, "unauthenticated", "نیاز به ورود دارید."), ); - await expect(apiRequest("/api/whatever")).rejects.toMatchObject({ - requestId: "req-from-header", + await expect(apiRequest("/api/organizations")).rejects.toMatchObject({ + status: 401, + code: "unauthenticated", }); }); }); describe("error console client", () => { afterEach(() => { - vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("omits unset filters from the query string", async () => { - const calls: string[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => { - calls.push(url); - return new Response(JSON.stringify({ items: [], total: 0, limit: 50, offset: 0 }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }), - ); - + const rpc = vi + .spyOn(getRealtimeClient(), "rpc") + .mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 } as unknown as never); await api.adminErrors({ status: "open", severity: "critical" }); - expect(calls[0]).toContain("status=open"); - expect(calls[0]).toContain("severity=critical"); + expect(rpc).toHaveBeenCalledTimes(1); + const [method, path] = rpc.mock.calls[0]; + expect(method).toBe("GET"); + expect(path).toContain("status=open"); + expect(path).toContain("severity=critical"); // An empty string means "all", and must not be sent as a filter value. - expect(calls[0]).not.toContain("source="); - expect(calls[0]).not.toContain("search="); + expect(path).not.toContain("source="); + expect(path).not.toContain("search="); }); it("sends no query string at all when there are no filters", async () => { - const calls: string[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => { - calls.push(url); - return new Response(JSON.stringify([]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }), - ); - + const rpc = vi + .spyOn(getRealtimeClient(), "rpc") + .mockResolvedValue([] as unknown as never); await api.adminErrorGroups(); - expect(calls[0]).not.toContain("?"); + const [, path] = rpc.mock.calls[0]; + expect(path).not.toContain("?"); }); }); describe("agy account client", () => { afterEach(() => { - vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); - function stubJson(body: unknown) { - const calls: { method: string; url: string; body: string }[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (url: string, init?: RequestInit) => { - calls.push({ - method: init?.method ?? "GET", - url, - body: String(init?.body ?? ""), - }); - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }), - ); - return calls; + function stubRpc(body: unknown) { + return vi + .spyOn(getRealtimeClient(), "rpc") + .mockResolvedValue(body as unknown as never); } it("posts to the login start endpoint", async () => { - const calls = stubJson({ login_id: "l1", auth_url: "https://accounts.google.com/..." }); + const rpc = stubRpc({ login_id: "l1", auth_url: "https://accounts.google.com/..." }); await api.agyStartLogin(); - expect(calls[0].method).toBe("POST"); - expect(calls[0].url).toContain("/api/admin/agy/accounts/login/start"); + const [method, path] = rpc.mock.calls[0]; + expect(method).toBe("POST"); + expect(path).toContain("/api/admin/agy/accounts/login/start"); }); it("sends the pasted callback URL back verbatim", async () => { - const calls = stubJson({ id: "a1", email: "x@gmail.com" }); + const rpc = stubRpc({ id: "a1", email: "x@gmail.com" }); await api.agyCompleteLogin({ login_id: "l1", callback_url: "http://localhost:51121/oauth-callback?code=c&state=s", }); - expect(calls[0].url).toContain("/api/admin/agy/accounts/login/complete"); - expect(calls[0].body).toContain("callback_url"); + const [method, path, sentBody] = rpc.mock.calls[0]; + expect(path).toContain("/api/admin/agy/accounts/login/complete"); + expect(JSON.stringify(sentBody)).toContain("callback_url"); }); it("routes activate/deactivate to the right verb", async () => { - const calls = stubJson({ id: "a1" }); + const rpc = stubRpc({ id: "a1" }); await api.agySetAccountActive("a1", false); await api.agySetAccountActive("a1", true); - expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/deactivate"); - expect(calls[1].url).toContain("/api/admin/agy/accounts/a1/activate"); + expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1/deactivate"); + expect(rpc.mock.calls[1][1]).toContain("/api/admin/agy/accounts/a1/activate"); }); it("deletes with the DELETE verb", async () => { - const calls = stubJson(null); + const rpc = stubRpc(null); await api.agyDeleteAccount("a1"); - expect(calls[0].method).toBe("DELETE"); - expect(calls[0].url).toContain("/api/admin/agy/accounts/a1"); + expect(rpc.mock.calls[0][0]).toBe("DELETE"); + expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1"); }); it("fetches the live model catalog for one account", async () => { - const calls = stubJson([ + const rpc = stubRpc([ { id: "gemini-3-flash", display_name: "Gemini 3 Flash", @@ -184,21 +145,23 @@ describe("agy account client", () => { }, ]); const models = await api.agyAccountModels("a1"); - expect(calls[0].method).toBe("GET"); - expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models"); + const [method, path] = rpc.mock.calls[0]; + expect(method).toBe("GET"); + expect(path).toContain("/api/admin/agy/accounts/a1/models"); expect(models.map((m) => m.id)).toContain("gemini-3-pro-high"); expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]); }); it("creates a model from the cascade — no manual names", async () => { - const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" }); + const rpc = stubRpc({ id: "m1", internal_name: "gemini-3-pro-high" }); await api.adminCreateModel({ internal_name: "gemini-3-pro-high", capabilities: ["text", "reasoning"], reasoning_effort: "high", }); - expect(calls[0].url).toContain("/api/admin/models"); - expect(calls[0].body).toContain("gemini-3-pro-high"); - expect(calls[0].body).toContain("reasoning_effort"); + const [method, path, sentBody] = rpc.mock.calls[0]; + expect(path).toContain("/api/admin/models"); + expect(JSON.stringify(sentBody)).toContain("gemini-3-pro-high"); + expect(JSON.stringify(sentBody)).toContain("reasoning_effort"); }); }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ec837f3..a969af5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -711,18 +711,31 @@ export interface WsEvent> { /** Every frame the server sends. Control frames and events share ``type``. */ export type WsServerFrame = - | { type: "hello"; user_id: string; server_time: string } + | { type: "hello"; user_id: string | null; server_time: string } | { type: "subscribed"; accepted: string[]; rejected: string[] } | { type: "unsubscribed"; channels: string[] } | { type: "pong" } | { type: "error"; message: string } - | { type: "event"; event: WsEvent }; + | { type: "event"; event: WsEvent } + /** Reply to a client ``rpc`` frame: the HTTP status and parsed JSON body of the + * internal dispatch, correlating on ``id``. */ + | { type: "rpc_result"; id: string; status: number; body: unknown }; /** Every frame the client may send. */ export type WsClientFrame = | { action: "subscribe"; channels: string[] } | { action: "unsubscribe"; channels: string[] } - | { action: "ping" }; + | { action: "ping" } + /** A request/response call into the server. ``id`` correlates the + * ``rpc_result``; ``path`` is the full mounted path including any query + * string. Every application call travels over this frame. */ + | { + action: "rpc"; + id: string; + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + path: string; + body: unknown; + }; /** Live run-step payload — what the task timeline renders as it arrives. */ export interface WsRunStepData { From 22034fd90afb503f509773a8cc958a18a109d6e5 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 14:23:40 +0330 Subject: [PATCH 07/12] fix(web): make WebSocket RPC self-heal against half-open sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A login over the socket could hang the full 60s timeout when the reverse proxy allowed the WebSocket handshake but dropped the server->client direction without closing the socket (so onclose never fired and the client saw a live-but-unresponsive link). - Track last inbound frame time (any frame, incl. hello/pong) as proof the link is bidirectional. - ensureLive(): before sending an RPC, replace a socket that has been silent for >30s (SOCKET_STALE_MS). - On timeout, recycle the connection and retry once on a fresh socket instead of hard-failing; only idempotent-damage-free flows are affected since the first attempt is torn down first. Reduces the observed "درخواست از طریق وب‌سوکت زمان‌بر شد" on login to a single reconnect+retry rather than a hard 60s wait. --- apps/web/src/lib/ws.ts | 126 ++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/apps/web/src/lib/ws.ts b/apps/web/src/lib/ws.ts index 3645b0c..1a9555b 100644 --- a/apps/web/src/lib/ws.ts +++ b/apps/web/src/lib/ws.ts @@ -48,6 +48,10 @@ const PONG_TIMEOUT_MS = 10_000; /** Upper bound on a single RPC before it is failed locally. Long enough for a * slow DB call, short enough that the UI does not hang forever. */ const RPC_TIMEOUT_MS = 60_000; +/** A socket that has not received any inbound frame for this long is presumed + * half-open: a proxy dropped the server→client direction without closing the + * socket, so it will never answer. We reconnect before sending on it. */ +const SOCKET_STALE_MS = 30_000; type EventListener = (event: WsEvent) => void; type StatusListener = (status: RealtimeStatus) => void; @@ -76,6 +80,8 @@ export class RealtimeClient { /** In-flight RPCs keyed by their client-generated id. */ private readonly pendingRpc = new Map(); private rpcSeq = 0; + /** Timestamp of the last frame we received; proves the link is bidirectional. */ + private lastActivityAt = 0; constructor(private readonly getToken: () => string | null = getStoredToken) {} @@ -142,6 +148,7 @@ export class RealtimeClient { socket.onopen = () => { settled = true; this.connectedToken = token; + this.lastActivityAt = Date.now(); this.reconnectAttempts = 0; this.setStatus("live"); this.startHeartbeat(); @@ -194,45 +201,84 @@ export class RealtimeClient { body: unknown, options: { signal?: AbortSignal } = {}, ): Promise { - return this.whenOpen().then( - () => - new Promise((resolve, reject) => { - const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`; - const timer = setTimeout(() => { - this.pendingRpc.delete(id); - reject( - new ApiError( - 0, - "timeout", - "درخواست از طریق وب‌سوکت زمان‌بر شد.", - ), - ); - }, RPC_TIMEOUT_MS); - this.pendingRpc.set(id, { - resolve: resolve as (value: unknown) => void, - reject, - timer, - }); - this.send({ action: "rpc", id, method, path, body }); - const signal = options.signal; - if (signal) { - signal.addEventListener( - "abort", - () => { - const pending = this.pendingRpc.get(id); - if (pending) { - clearTimeout(pending.timer); - this.pendingRpc.delete(id); - pending.reject( - new ApiError(0, "aborted", "درخواست لغو شد."), - ); - } - }, - { once: true }, - ); - } - }), - ); + const run = (allowRetry: boolean): Promise => + this.ensureLive().then( + () => + new Promise((resolve, reject) => { + const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`; + const timer = setTimeout(() => { + this.pendingRpc.delete(id); + if (allowRetry) { + // The link looked alive but never answered: it is probably + // half-open (a proxy dropped the server→client direction without + // closing the socket, so onclose never fires). Recycle it and + // retry once on a fresh connection so login/load does not hard-fail. + this.forceReconnect(); + run(false).then(resolve, reject); + return; + } + reject( + new ApiError( + 0, + "timeout", + "درخواست از طریق وب‌سوکت زمان‌بر شد.", + ), + ); + }, RPC_TIMEOUT_MS); + this.pendingRpc.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timer, + }); + this.send({ action: "rpc", id, method, path, body }); + const signal = options.signal; + if (signal) { + signal.addEventListener( + "abort", + () => { + const pending = this.pendingRpc.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRpc.delete(id); + pending.reject( + new ApiError(0, "aborted", "درخواست لغو شد."), + ); + } + }, + { once: true }, + ); + } + }), + ); + return run(true); + } + + /** Open (reconnecting first if the socket has gone silent) and resolve when + * ready to send. A socket with no inbound frame for ``SOCKET_STALE_MS`` is + * treated as half-open and replaced before we send on it. */ + private ensureLive(): Promise { + const token = this.getToken(); + const stale = + !!this.socket && + this.socket.readyState === WebSocket.OPEN && + Date.now() - this.lastActivityAt > SOCKET_STALE_MS; + if (stale) this.openWith(token); + return this.whenOpen(); + } + + /** Tear down the current socket unconditionally and open a fresh one. Used + * when a request times out on a link that never reported a close. */ + private forceReconnect(): void { + this.closedByUs = false; + if (this.socket) { + try { + this.socket.close(); + } catch { + /* already gone */ + } + this.socket = null; + } + this.openWith(this.getToken()); } /** Resolve once the socket is OPEN for the current token (opening if needed). */ @@ -304,6 +350,8 @@ export class RealtimeClient { } catch { return; } + // Any inbound frame proves the server→client direction is alive. + this.lastActivityAt = Date.now(); switch (frame.type) { case "event": From e50c86c4514cfd37d69ca65ff23a90350ed579b4 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 16:07:37 +0330 Subject: [PATCH 08/12] fix(web): stop authenticating WebSocket reconnect storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console evidence: on the authenticated layout, api.me() opened wss://.../api/ws?token= and the browser logged "WebSocket is closed before the connection is established", then an uncaught connection_closed rejection — a self-inflicted reconnect storm. Root cause (two halves): 1. connectedToken was only assigned inside onopen, so during the CONNECTING window every other caller (RealtimeProvider's 2s poll, rpc's whenOpen) saw connectedToken !== token and called openWith again, tearing down the socket it had just dialled. 2. Replacing a socket left its late onclose to run onSocketClosed, which nulled this.socket and connectedToken — clobbering the successor and firing a spurious reconnect + rejected openPromise. Fix: - Set connectedToken the moment we commit to a dial (in openWith), so concurrent callers wait on the same openPromise instead of re-dialling. - Guard onopen/onmessage/onclose with `this.socket !== socket` so a replaced socket's late callbacks are ignored and cannot clobber its successor. Anonymous login escaped this (both tokens null); an authenticated connection (a real JWT) triggered it, matching the reported failure. --- apps/web/src/lib/ws.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/ws.ts b/apps/web/src/lib/ws.ts index 1a9555b..566627d 100644 --- a/apps/web/src/lib/ws.ts +++ b/apps/web/src/lib/ws.ts @@ -142,12 +142,21 @@ export class RealtimeClient { : websocketUrl(); const socket = new WebSocket(url); this.socket = socket; + // Record the token of THIS attempt immediately, not only on open. While the + // socket is CONNECTING, ``connectedToken`` must already equal the token we + // are dialling, so concurrent callers (the provider's poll, another RPC) + // wait on ``openPromise`` instead of tearing the socket down and dialling a + // second one — which is what produced "closed before established" + a + // reconnect storm. + this.connectedToken = token; this.openPromise = new Promise((resolve, reject) => { let settled = false; socket.onopen = () => { + // This socket may have been replaced (token change) before its handshake + // finished; if so, its late "open" belongs to a link we already dropped. + if (this.socket !== socket) return; settled = true; - this.connectedToken = token; this.lastActivityAt = Date.now(); this.reconnectAttempts = 0; this.setStatus("live"); @@ -160,8 +169,17 @@ export class RealtimeClient { } resolve(); }; - socket.onmessage = (message) => this.handleFrame(message.data); + socket.onmessage = (message) => { + // Ignore frames from a socket that was already replaced (e.g. a token + // change opened a new one): its late frames belong to a dead link. + if (this.socket !== socket) return; + this.handleFrame(message.data); + }; socket.onclose = () => { + // A replaced socket's late close must not clobber its successor: without + // this guard, closing the old socket nulls ``this.socket``/token and + // fires a spurious reconnect -- the "closed before established" storm. + if (this.socket !== socket) return; if (!settled) { reject( new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."), From 91a8ae6c63e0c1159c3a9b06d5b56821ce398f4d Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 16:54:33 +0330 Subject: [PATCH 09/12] fix(admin/models): collapse agy effort variants into one clean option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model dropdown labeled each option as "{display} ({id})", and since agy's display name already bakes in the effort, entries rendered like "Gemini 3.8 Flash (High) (gemini-3.8-flash-high)" — the name, its effort, AND the raw id all in one, redundant with the separate effort field below. Group the live catalog into families by base name (effort qualifier stripped): "Gemini 3.8 Flash" is now one option, and the reasoning effort is chosen in the existing effort dropdown. On create, the family + chosen effort resolves back to the concrete agy catalog entry (gemini-3.8-flash-medium etc.), so the saved internal_name and reasoning_effort are byte-for-byte what a direct pick produced before. Frontend-only; no backend/catalog change. tsc --noEmit clean. --- apps/web/src/app/(app)/admin/models/page.tsx | 114 ++++++++++++++----- 1 file changed, 87 insertions(+), 27 deletions(-) diff --git a/apps/web/src/app/(app)/admin/models/page.tsx b/apps/web/src/app/(app)/admin/models/page.tsx index 87cb417..c143dee 100644 --- a/apps/web/src/app/(app)/admin/models/page.tsx +++ b/apps/web/src/app/(app)/admin/models/page.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { - effortsForModel, effortLabel, type AdminModel, type AgyAccount, @@ -23,6 +22,65 @@ import { LoadingState, Select, } from "@agy/ui"; + +/** Effort tokens an agy model id/name can encode, weakest -> strongest. */ +const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh"]; + +/** The single effort one agy catalog entry encodes, or ``null`` for a model + * that is not effort-specific (e.g. Claude "(Thinking)"). Detected from the + * id's dash-separated parts to avoid substring accidents ("xhigh" not "high"). */ +function entryEffort(m: AgyAccountModel): string | null { + const parts = m.id.toLowerCase().split(/[-_]/); + for (const token of ["xhigh", "high", "medium", "low", "minimal"]) { + if (parts.includes(token)) return token; + } + const supported = m.supported_efforts ?? []; + return supported.length === 1 ? supported[0] : null; +} + +/** Base label with any single-effort qualifier (" (High)") removed so effort + * variants of one family collapse into a single dropdown option. */ +function baseModelName(m: AgyAccountModel): string { + const stripped = m.display_name + .replace(/\s*\((high|medium|low|minimal|xhigh)\)/i, "") + .trim(); + return stripped || m.display_name; +} + +/** A model family: N agy catalog entries that share a base name and differ only + * by reasoning effort (e.g. gemini-3.8-flash-high/-medium/-low). */ +interface ModelFamily { + base: string; + entries: AgyAccountModel[]; + efforts: string[]; + supportsThinking: boolean; + supportsImages: boolean; +} + +/** Group the live catalog into families for the model dropdown. */ +function groupCatalog(catalog: AgyAccountModel[] | null): ModelFamily[] { + const groups = new Map(); + for (const m of catalog ?? []) { + const base = baseModelName(m); + let g = groups.get(base); + if (!g) { + g = { base, entries: [], efforts: [], supportsThinking: false, supportsImages: false }; + groups.set(base, g); + } + g.entries.push(m); + g.supportsThinking = g.supportsThinking || !!m.supports_thinking; + g.supportsImages = g.supportsImages || !!m.supports_images; + const own = entryEffort(m); + if (own && !g.efforts.includes(own)) g.efforts.push(own); + for (const e of m.supported_efforts ?? []) { + if (!g.efforts.includes(e)) g.efforts.push(e); + } + } + for (const g of groups.values()) { + g.efforts.sort((a, b) => EFFORT_ORDER.indexOf(a) - EFFORT_ORDER.indexOf(b)); + } + return [...groups.values()]; +} import { ApiError, api } from "@/lib/api"; /** @@ -115,6 +173,10 @@ export default function AdminModelsPage() { } }, []); + // The model dropdown shows one option per family (base name), not one per + // effort variant; the effort is chosen separately in the field below. + const groupedModels = React.useMemo(() => groupCatalog(catalog), [catalog]); + const onProviderChange = (value: string) => { setProviderId(value); // One provider exists, but the flow is wired for real: picking it reloads @@ -136,24 +198,19 @@ export default function AdminModelsPage() { const onModelChange = (value: string) => { setModelId(value); - const selected = (catalog ?? []).find((m) => m.id === value); - const supported = selected ? selected.supported_efforts : effortsForModel(value); + const family = groupedModels.find((g) => g.base === value); + const supported = family ? family.efforts : []; - // Pick default effort if supported by this model - if (selected?.default_effort && supported.includes(selected.default_effort)) { - setEffort(selected.default_effort); - } else if (supported.length > 0) { - setEffort(supported.includes("high") ? "high" : supported[0]); - } else { - setEffort(""); - } + // Default to the strongest effort the family offers (matches agy's default); + // a family with no efforts (e.g. Claude) leaves the field at its placeholder. + setEffort(supported.includes("high") ? "high" : (supported[0] ?? "")); - // Auto-detect capabilities based on agy model metadata + // Auto-detect capabilities from the family metadata. const nextCaps: Capability[] = ["text"]; - if (selected?.supports_thinking || supported.length > 0) { + if (family?.supportsThinking || supported.length > 0) { nextCaps.push("reasoning"); } - if (selected?.supports_images) { + if (family?.supportsImages) { nextCaps.push("vision"); } setCaps(nextCaps); @@ -176,12 +233,20 @@ export default function AdminModelsPage() { setFormError("حداقل یک توانایی انتخاب کنید."); return; } - const selectedModel = (catalog ?? []).find((m) => m.id === modelId); + const family = groupedModels.find((g) => g.base === modelId); + // Resolve the family + chosen effort back to the concrete agy catalog entry + // (e.g. "Gemini 3.8 Flash" + medium -> gemini-3.8-flash-medium), so the saved + // internal_name and reasoning_effort are exactly what a direct pick produced. + const resolved = family + ? (family.entries.find((e) => entryEffort(e) === effort) ?? + family.entries.find((e) => entryEffort(e) === "high") ?? + family.entries[0]) + : undefined; setCreating(true); try { const created = await api.adminCreateModel({ - internal_name: modelId, - display_name_for_admin: selectedModel?.display_name || modelId, + internal_name: resolved?.id ?? modelId, + display_name_for_admin: resolved?.display_name || modelId, capabilities: caps, input_price_per_1m: Number(inputPrice) || 0, output_price_per_1m: Number(outputPrice) || 0, @@ -200,20 +265,15 @@ export default function AdminModelsPage() { } }; - const selectedModel = (catalog ?? []).find((m) => m.id === modelId); - const efforts: ReasoningEffort[] = selectedModel - ? selectedModel.supported_efforts - : modelId - ? effortsForModel(modelId) - : []; + const selectedFamily = groupedModels.find((g) => g.base === modelId); + const efforts: ReasoningEffort[] = selectedFamily + ? (selectedFamily.efforts as ReasoningEffort[]) + : []; const accountOptions = accounts.map((a) => ({ value: a.id, label: a.email || a.name || a.id, })); - const modelOptions = (catalog ?? []).map((m) => ({ - value: m.id, - label: m.display_name && m.display_name !== m.id ? `${m.display_name} (${m.id})` : m.id, - })); + const modelOptions = groupedModels.map((g) => ({ value: g.base, label: g.base })); const effortOptions = [ { value: "", label: "پیش‌فرض سامانه" }, ...efforts.map((e) => ({ value: e, label: effortLabel(e) })), From 3711ca551304f14684bf8717b1219412073c1aa8 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 17:11:56 +0330 Subject: [PATCH 10/12] fix(admin/models): make add-to-list visible + clarify account->model loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Newly added model is highlighted (2.5s) and the list scrolls into view so the owner sees it land immediately after create. - Model dropdown shows an explicit 'loading catalog…' placeholder while the selected account's live catalogue is fetched, instead of a misleading 'no catalog' message during the request. --- apps/web/src/app/(app)/admin/models/page.tsx | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(app)/admin/models/page.tsx b/apps/web/src/app/(app)/admin/models/page.tsx index c143dee..68cbdd1 100644 --- a/apps/web/src/app/(app)/admin/models/page.tsx +++ b/apps/web/src/app/(app)/admin/models/page.tsx @@ -116,6 +116,10 @@ export default function AdminModelsPage() { const [outputPrice, setOutputPrice] = React.useState(""); const [creating, setCreating] = React.useState(false); const [formError, setFormError] = React.useState(null); + /** The id of the model just added, briefly highlighted so the owner sees the + * list updated without hunting for it (the list sits below the form). */ + const [justAddedId, setJustAddedId] = React.useState(null); + const listRef = React.useRef(null); const load = React.useCallback(async () => { setLoading(true); @@ -253,6 +257,9 @@ export default function AdminModelsPage() { reasoning_effort: effort || undefined, }); setModels((current) => [created, ...current]); + setJustAddedId(created.id); + window.setTimeout(() => setJustAddedId(null), 2500); + listRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); setModelId(""); setEffort(""); setInputPrice(""); @@ -328,9 +335,11 @@ export default function AdminModelsPage() { options={ !accountId ? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }] - : modelOptions.length === 0 - ? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }] - : modelOptions + : catalogLoading + ? [{ value: "", label: "در حال بارگذاری کاتالوگ…" }] + : modelOptions.length === 0 + ? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }] + : modelOptions } disabled={!accountId || catalogLoading || modelOptions.length === 0} /> @@ -421,6 +430,7 @@ export default function AdminModelsPage() { description="بدون مدل فعال، هیچ حالت مسیریابی قابل اجرا نیست." /> ) : ( +
فهرست مدل‌ها
@@ -437,7 +447,13 @@ export default function AdminModelsPage() { {models.map((model) => ( - + {model.internal_name}

@@ -477,6 +493,7 @@ export default function AdminModelsPage() {

+
)} ); From f2983afaec2bd539f80557cf04687a46a557a1dc Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 17:29:12 +0330 Subject: [PATCH 11/12] fix(admin/models): make account->model and model->effort cascade fire on first pick The account and model