Merge origin/dev: لایهٔ realtime (WebSocket/RPC) + فلو دعوت عضو
ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگهای صفحهٔ مدلها) با ۸ کامیت محلی (ارائهدهندهٔ agy + رفع فلو دعوت).
تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.
راهحل:
- `app/main.py`: فقط `health` و `realtime_router` میمانند؛ ایمپورتهای اضافی
حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
`GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
قابل دسترس باشند.
`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد میشود،
پس هیچ تغییری در فراخوانیها لازم نبود.
رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر میکرد و `useRealtime` استثنا میداد. حالا اسکلت
همانطور که در `app/layout.tsx` سیمکشی شده رندر میشود و سوکت در تست stub
شده است.
اعتبارسنجی روی درخت ادغامشده:
- بکاند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.
نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` میآیند و به این ادغام ربطی
ندارند؛ دستنخورده رها شدند.
This commit is contained in:
@@ -29,6 +29,7 @@ venv/
|
||||
# env & secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -31,12 +31,16 @@ 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
|
||||
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
|
||||
@@ -106,9 +110,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-",
|
||||
)
|
||||
|
||||
|
||||
@@ -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]]]
|
||||
|
||||
@@ -209,6 +213,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."""
|
||||
|
||||
@@ -309,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(
|
||||
@@ -436,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
|
||||
@@ -453,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(" ")
|
||||
@@ -579,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:
|
||||
@@ -593,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}: محدودیت نرخ")
|
||||
@@ -630,24 +646,30 @@ 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:
|
||||
raise ProviderUnavailableError(
|
||||
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
|
||||
)
|
||||
raise ProviderUnavailableError("این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید.")
|
||||
return await self._fetch_models(account)
|
||||
|
||||
async def _fetch_models(self, account: AgyAccount) -> list[str]:
|
||||
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:
|
||||
@@ -663,11 +685,218 @@ 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
|
||||
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: <model_id>\t<display_name>
|
||||
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):
|
||||
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
|
||||
|
||||
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):
|
||||
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 _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]) -> 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]:
|
||||
@@ -758,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 = {
|
||||
@@ -783,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(
|
||||
@@ -798,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]}"
|
||||
)
|
||||
return _parse_generate_response(
|
||||
response.json(), model=model, started=started
|
||||
f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}"
|
||||
)
|
||||
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 پاسخ نداد.")
|
||||
@@ -833,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,
|
||||
@@ -864,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] = []
|
||||
@@ -914,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,14 @@ class AiModelRepository:
|
||||
await self.session.flush()
|
||||
return _model_to_domain(row)
|
||||
|
||||
async def delete(self, model_id: str) -> bool:
|
||||
row = await self.session.get(AiModelRow, model_id)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
|
||||
"""Choose a model for a routing mode.
|
||||
|
||||
|
||||
@@ -215,6 +215,28 @@ async def create_model(
|
||||
return _model_out(model)
|
||||
|
||||
|
||||
@router.delete("/models/{model_id}", status_code=status.HTTP_200_OK)
|
||||
async def delete_model(
|
||||
model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
repo = AiModelRepository(session)
|
||||
existing = await repo.get(model_id)
|
||||
if existing is None:
|
||||
raise NotFoundError("مدلی با این شناسه یافت نشد.")
|
||||
await repo.delete(model_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
|
||||
target_type="model", target_id=model_id,
|
||||
metadata={"internal_name": existing.internal_name, "deleted": True},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -105,6 +105,15 @@ class AgyTestOut(BaseModel):
|
||||
output_tokens: int
|
||||
|
||||
|
||||
class AgyAccountModelOut(BaseModel):
|
||||
id: str
|
||||
display_name: str
|
||||
supports_thinking: bool = False
|
||||
supported_efforts: list[str] = Field(default_factory=list)
|
||||
default_effort: str | None = None
|
||||
supports_images: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -331,16 +340,16 @@ async def test_agy_account(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts/{credential_id}/models", response_model=list[str])
|
||||
@router.get("/accounts/{credential_id}/models", response_model=list[AgyAccountModelOut])
|
||||
async def list_account_models(
|
||||
credential_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[str]:
|
||||
) -> list[AgyAccountModelOut]:
|
||||
"""Live model catalog of one account — fetched from Google on every call.
|
||||
|
||||
The admin console calls this when an account is selected, so the model
|
||||
list always reflects that subscription's current entitlement instead of a
|
||||
cached snapshot.
|
||||
list always reflects that subscription's current entitlement along with
|
||||
the supported reasoning efforts for each model.
|
||||
"""
|
||||
from app.core.errors import ValidationError
|
||||
from app.infrastructure.adapters.agy import AgyModelAdapter
|
||||
@@ -352,7 +361,18 @@ async def list_account_models(
|
||||
|
||||
adapter = AgyModelAdapter() # stateless: one forced account
|
||||
try:
|
||||
return await adapter.available_models_for(credential)
|
||||
models = await adapter.available_models_for(credential)
|
||||
return [
|
||||
AgyAccountModelOut(
|
||||
id=m.id,
|
||||
display_name=m.display_name,
|
||||
supports_thinking=m.supports_thinking,
|
||||
supported_efforts=m.supported_efforts,
|
||||
default_effort=m.default_effort,
|
||||
supports_images=m.supports_images,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - the console wants the reason
|
||||
|
||||
@@ -42,5 +42,22 @@ async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"sandbox": settings.sandbox_provider,
|
||||
"payment": settings.payment_provider,
|
||||
}
|
||||
|
||||
# Live-feed visibility: how many browser sockets this process is serving,
|
||||
# and whether the cross-process relay (Redis) is connected. An operator
|
||||
# wondering "is realtime working?" should not have to read the logs.
|
||||
try:
|
||||
from app.interfaces.realtime.bus import get_bus
|
||||
from app.interfaces.realtime.router import realtime_manager
|
||||
|
||||
manager = realtime_manager()
|
||||
checks["realtime"] = {
|
||||
"connections": manager.connection_count,
|
||||
"channels": manager.channel_count,
|
||||
"redis_relay": "connected" if get_bus().redis_healthy else "in_process_only",
|
||||
}
|
||||
except Exception: # noqa: BLE001 - health must answer regardless
|
||||
checks["realtime"] = "unavailable"
|
||||
|
||||
healthy = checks.get("database") == "ok"
|
||||
return {"status": "ok" if healthy else "degraded", "checks": checks}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""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 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 | 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)
|
||||
)
|
||||
_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)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""The WebSocket endpoint — one socket per browser, every live feed and every
|
||||
RPC on it.
|
||||
|
||||
``GET /api/ws?token=<jwt>``
|
||||
|
||||
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. Server->client frames
|
||||
share the ``type`` discriminator; client->server frames use ``action``::
|
||||
|
||||
client -> {"action": "subscribe", "channels": ["org:…"]}
|
||||
client -> {"action": "unsubscribe", "channels": ["task:…"]}
|
||||
client -> {"action": "ping"}
|
||||
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": "…"}
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
#: 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,
|
||||
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)
|
||||
|
||||
_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(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 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)
|
||||
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))
|
||||
if user is not None and user.is_active:
|
||||
raw_token = token
|
||||
except AuthenticationError:
|
||||
user = None
|
||||
|
||||
if token and user is None:
|
||||
await websocket.close(code=1008, reason="unauthorized")
|
||||
return None
|
||||
|
||||
await websocket.accept()
|
||||
return Connection(
|
||||
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(connection: Connection, raw: str) -> None:
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
_send_frame(connection, {"type": "error", "message": "malformed JSON frame"})
|
||||
return
|
||||
if not isinstance(message, dict):
|
||||
_send_frame(connection, {"type": "error", "message": "frame must be an object"})
|
||||
return
|
||||
|
||||
action = message.get("action")
|
||||
|
||||
if action == "ping":
|
||||
_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(
|
||||
str(channel) for channel in (message.get("channels") or []) if isinstance(channel, str)
|
||||
)
|
||||
|
||||
if action == "subscribe":
|
||||
accepted, rejected = await _subscribe(connection, channels)
|
||||
_send_frame(
|
||||
connection, {"type": "subscribed", "accepted": accepted, "rejected": rejected}
|
||||
)
|
||||
return
|
||||
|
||||
if action == "unsubscribe":
|
||||
await _manager.unsubscribe(connection, list(channels))
|
||||
_send_frame(connection, {"type": "unsubscribed", "channels": channels})
|
||||
return
|
||||
|
||||
_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(
|
||||
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 or "",
|
||||
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()
|
||||
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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,
|
||||
invites,
|
||||
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(invites.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
|
||||
+36
-26
@@ -15,19 +15,11 @@ 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,
|
||||
invites,
|
||||
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
|
||||
from app.interfaces.realtime.router import router as realtime_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -63,8 +55,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."""
|
||||
@@ -85,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
|
||||
@@ -114,25 +126,23 @@ 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(invites.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")
|
||||
|
||||
@application.get("/", tags=["meta"])
|
||||
async def root() -> dict:
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -39,6 +43,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
|
||||
|
||||
+131
-52
@@ -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,10 +760,9 @@ 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")
|
||||
)
|
||||
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
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.
|
||||
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
|
||||
assert len(fetches) == 1
|
||||
@@ -817,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"]
|
||||
@@ -829,7 +804,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",
|
||||
@@ -840,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()
|
||||
@@ -865,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)"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""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
|
||||
# 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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_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):
|
||||
# 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
|
||||
@@ -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*",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@agy/web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<string | null>(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);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
effortsForModel,
|
||||
effortLabel,
|
||||
type AdminModel,
|
||||
type AgyAccount,
|
||||
type AgyAccountModel,
|
||||
type Capability,
|
||||
type ReasoningEffort,
|
||||
} from "@agy/contracts";
|
||||
@@ -22,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<string, ModelFamily>();
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -29,8 +88,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 +106,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<string[] | null>(null);
|
||||
const [catalog, setCatalog] = React.useState<AgyAccountModel[] | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = React.useState(false);
|
||||
const [catalogError, setCatalogError] = React.useState<string | null>(null);
|
||||
const [modelId, setModelId] = React.useState("");
|
||||
@@ -57,6 +116,13 @@ export default function AdminModelsPage() {
|
||||
const [outputPrice, setOutputPrice] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(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<string | null>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
/** Inline delete-confirmation: which row is armed, and which is in flight. */
|
||||
const [confirmId, setConfirmId] = React.useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -114,6 +180,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
|
||||
@@ -135,14 +205,22 @@ 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 family = groupedModels.find((g) => g.base === value);
|
||||
const supported = family ? family.efforts : [];
|
||||
|
||||
// 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 from the family metadata.
|
||||
const nextCaps: Capability[] = ["text"];
|
||||
if (family?.supportsThinking || supported.length > 0) {
|
||||
nextCaps.push("reasoning");
|
||||
}
|
||||
if (family?.supportsImages) {
|
||||
nextCaps.push("vision");
|
||||
}
|
||||
setCaps(nextCaps);
|
||||
};
|
||||
|
||||
const toggleCap = (cap: Capability) => {
|
||||
@@ -162,16 +240,29 @@ export default function AdminModelsPage() {
|
||||
setFormError("حداقل یک توانایی انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
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,
|
||||
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,
|
||||
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("");
|
||||
@@ -184,13 +275,32 @@ export default function AdminModelsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : [];
|
||||
const onDelete = async (model: AdminModel) => {
|
||||
setDeletingId(model.id);
|
||||
try {
|
||||
await api.adminDeleteModel(model.id);
|
||||
setModels((current) => current.filter((m) => m.id !== model.id));
|
||||
setConfirmId(null);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "حذف مدل ناموفق بود.");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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((id) => ({ value: id, label: id }));
|
||||
const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) }));
|
||||
const modelOptions = groupedModels.map((g) => ({ value: g.base, label: g.base }));
|
||||
const effortOptions = [
|
||||
{ value: "", label: "پیشفرض سامانه" },
|
||||
...efforts.map((e) => ({ value: e, label: effortLabel(e) })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
@@ -226,7 +336,7 @@ export default function AdminModelsPage() {
|
||||
options={
|
||||
accountOptions.length === 0
|
||||
? [{ value: "", label: "اکانتی ثبت نشده — ابتدا اکانت agy بسازید" }]
|
||||
: accountOptions
|
||||
: [{ value: "", label: "اکانت agy را انتخاب کنید" }, ...accountOptions]
|
||||
}
|
||||
hint="کاتالوگ مدلها از همین اکانت خوانده میشود."
|
||||
/>
|
||||
@@ -241,9 +351,11 @@ export default function AdminModelsPage() {
|
||||
options={
|
||||
!accountId
|
||||
? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }]
|
||||
: catalogLoading
|
||||
? [{ value: "", label: "در حال بارگذاری کاتالوگ…" }]
|
||||
: modelOptions.length === 0
|
||||
? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }]
|
||||
: modelOptions
|
||||
: [{ value: "", label: "یک مدل انتخاب کنید" }, ...modelOptions]
|
||||
}
|
||||
disabled={!accountId || catalogLoading || modelOptions.length === 0}
|
||||
/>
|
||||
@@ -251,12 +363,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."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -328,6 +446,7 @@ export default function AdminModelsPage() {
|
||||
description="بدون مدل فعال، هیچ حالت مسیریابی قابل اجرا نیست."
|
||||
/>
|
||||
) : (
|
||||
<div ref={listRef}>
|
||||
<Card>
|
||||
<CardTitle>فهرست مدلها</CardTitle>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
@@ -340,11 +459,18 @@ export default function AdminModelsPage() {
|
||||
<th className="px-2 py-2 text-right font-medium">ورودی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">خروجی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
|
||||
<th className="px-2 py-2 text-right font-medium">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{models.map((model) => (
|
||||
<tr key={model.id} className="border-b border-surface-border/60">
|
||||
<tr
|
||||
key={model.id}
|
||||
className={
|
||||
"border-b border-surface-border/60" +
|
||||
(model.id === justAddedId ? " bg-emerald-500/10" : "")
|
||||
}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
@@ -378,12 +504,47 @@ export default function AdminModelsPage() {
|
||||
{model.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{confirmId === model.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="danger"
|
||||
loading={deletingId === model.id}
|
||||
onClick={() => void onDelete(model)}
|
||||
>
|
||||
تأیید حذف
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deletingId === model.id}
|
||||
onClick={() => setConfirmId(null)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-danger hover:bg-danger/10"
|
||||
onClick={() => setConfirmId(model.id)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -69,6 +70,44 @@ export default function BillingPage() {
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
const [checkoutUrl, setCheckoutUrl] = React.useState<string | null>(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);
|
||||
|
||||
@@ -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<string | null>(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);
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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);
|
||||
|
||||
@@ -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<TaskStatus, string> = {
|
||||
@@ -64,6 +65,112 @@ export default function TaskDetailPage({
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(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<string, unknown> };
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -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<string | null>(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<string, unknown> };
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#0b0d12"/>
|
||||
<rect x="0.5" y="0.5" width="31" height="31" rx="7.5" stroke="#1e293b" stroke-width="1"/>
|
||||
<defs>
|
||||
<linearGradient id="brandGrad" x1="6" y1="6" x2="26" y2="26" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#38bdf8"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#a855f7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="16" cy="10" r="2.5" fill="url(#brandGrad)"/>
|
||||
<circle cx="9" cy="22" r="2.5" fill="url(#brandGrad)"/>
|
||||
<circle cx="23" cy="22" r="2.5" fill="url(#brandGrad)"/>
|
||||
<path d="M16 10L9 22M16 10L23 22M9 22H23" stroke="url(#brandGrad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="18" r="1.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 896 B |
@@ -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 (
|
||||
<html lang="fa" dir="rtl" className="dark">
|
||||
<body>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
<SessionProvider>
|
||||
{/* One socket for the whole app: every live feed rides on it. */}
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<span className="flex items-center gap-1.5" title={label}>
|
||||
<span
|
||||
className={cx(
|
||||
"inline-block h-1.5 w-1.5 rounded-full",
|
||||
tone === "success" && "bg-emerald-400",
|
||||
tone === "warning" && "bg-amber-400 animate-pulse",
|
||||
tone === "neutral" && "bg-slate-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
||||
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
||||
const [balance, setBalance] = React.useState<number | null>(null);
|
||||
@@ -195,6 +228,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CreditBadge organizationId={activeOrganization?.id} />
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<LiveIndicator />
|
||||
</span>
|
||||
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
|
||||
+70
-11
@@ -7,9 +7,32 @@
|
||||
*/
|
||||
|
||||
import type { ApiErrorBody } from "@agy/contracts";
|
||||
import { getRealtimeClient } from "@/lib/ws";
|
||||
|
||||
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;
|
||||
@@ -67,15 +90,34 @@ export interface RequestOptions {
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
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<T>(path, method, body, signal);
|
||||
}
|
||||
|
||||
const client = getRealtimeClient();
|
||||
return client.rpc<T>(method, path, body === undefined ? null : body, { signal });
|
||||
}
|
||||
|
||||
/** Plain-HTTP implementation kept only for the two health probes. */
|
||||
async function fetchHealth<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const authToken = token ?? getStoredToken();
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
response = await fetch(`${getApiBaseUrl()}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
@@ -95,8 +137,6 @@ export async function apiRequest<T>(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);
|
||||
@@ -458,9 +498,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<string[]>(`/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<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
|
||||
adminCreateModel: (body: {
|
||||
internal_name: string;
|
||||
@@ -474,6 +529,10 @@ export const api = {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
adminDeleteModel: (modelId: string) =>
|
||||
apiRequest<void>(`/api/admin/models/${encodeURIComponent(modelId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
adminRoutingPolicies: () =>
|
||||
apiRequest<import("@agy/contracts").AdminRoutingPolicy[]>("/api/admin/routing-policies"),
|
||||
adminCreateRoutingPolicy: (body: {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"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";
|
||||
|
||||
interface RealtimeContextValue {
|
||||
status: RealtimeStatus;
|
||||
subscribe: (channels: string[]) => void;
|
||||
unsubscribe: (channels: string[]) => void;
|
||||
}
|
||||
|
||||
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
|
||||
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
const client = React.useMemo(() => getRealtimeClient(), []);
|
||||
const [status] = useRealtimeState(client);
|
||||
|
||||
// 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 = () => 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.
|
||||
const interval = setInterval(connect, 2_000);
|
||||
window.addEventListener("storage", connect);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("storage", connect);
|
||||
client.disconnect();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const value = React.useMemo<RealtimeContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
subscribe: (channels) => client.subscribe(channels),
|
||||
unsubscribe: (channels) => client.unsubscribe(channels),
|
||||
}),
|
||||
[status, client],
|
||||
);
|
||||
|
||||
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
function useRealtimeState(client: ReturnType<typeof getRealtimeClient>): [RealtimeStatus] {
|
||||
const [status, setStatus] = React.useState<RealtimeStatus>(() => 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 <RealtimeProvider>");
|
||||
}
|
||||
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]),
|
||||
);
|
||||
}
|
||||
@@ -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.4";
|
||||
|
||||
/** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 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.
|
||||
* * **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";
|
||||
|
||||
/** 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;
|
||||
/** 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;
|
||||
|
||||
interface PendingRpc {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: ApiError) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private status: RealtimeStatus = "idle";
|
||||
private readonly listeners = new Set<EventListener>();
|
||||
private readonly statusListeners = new Set<StatusListener>();
|
||||
private readonly subscriptions = new Set<string>();
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | 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<void> | null = null;
|
||||
/** In-flight RPCs keyed by their client-generated id. */
|
||||
private readonly pendingRpc = new Map<string, PendingRpc>();
|
||||
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) {}
|
||||
|
||||
// -- lifecycle -----------------------------------------------------------
|
||||
|
||||
/** Open (or reopen) the socket for the token the getter currently returns. */
|
||||
connect(): void {
|
||||
const token = this.getToken();
|
||||
// Already open for this exact token: nothing to do.
|
||||
if (
|
||||
this.socket &&
|
||||
(this.socket.readyState === WebSocket.OPEN ||
|
||||
this.socket.readyState === WebSocket.CONNECTING) &&
|
||||
this.connectedToken === token
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.openWith(token);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closedByUs = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد."));
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.socket) {
|
||||
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;
|
||||
// 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<void>((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.lastActivityAt = Date.now();
|
||||
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) => {
|
||||
// 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", "اتصال پیش از برقراری قطع شد."),
|
||||
);
|
||||
}
|
||||
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(
|
||||
MAX_BACKOFF_MS,
|
||||
MIN_BACKOFF_MS * 2 ** this.reconnectAttempts,
|
||||
);
|
||||
this.reconnectAttempts += 1;
|
||||
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<T>(
|
||||
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body: unknown,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const run = (allowRetry: boolean): Promise<T> =>
|
||||
this.ensureLive().then(
|
||||
() =>
|
||||
new Promise<T>((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<void> {
|
||||
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). */
|
||||
private whenOpen(): Promise<void> {
|
||||
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 {
|
||||
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, and RPCs await whenOpen().
|
||||
}
|
||||
|
||||
private handleFrame(raw: unknown): void {
|
||||
if (typeof raw !== "string") return;
|
||||
let frame: WsServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(raw) as WsServerFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Any inbound frame proves the server→client direction is alive.
|
||||
this.lastActivityAt = Date.now();
|
||||
|
||||
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;
|
||||
case "rpc_result":
|
||||
this.resolveRpc(frame);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRpc(frame: Extract<WsServerFrame, { type: "rpc_result" }>): 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 {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
// One broken page must not silence every other page's feed.
|
||||
console.error("realtime: event listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
for (const listener of [...this.statusListeners]) {
|
||||
try {
|
||||
listener(status);
|
||||
} catch (error) {
|
||||
console.error("realtime: status listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
@@ -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,142 +42,126 @@ 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(["gemini-3-flash", "gemini-3-pro-high"]);
|
||||
const rpc = stubRpc([
|
||||
{
|
||||
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");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { RealtimeProvider } from "@/lib/realtime";
|
||||
import { APP_VERSION, APP_VERSION_LABEL } from "@/lib/version";
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,32 @@ vi.mock("@/lib/session", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The shell renders a live-feed indicator, so it must be mounted the way the
|
||||
// app wires it in ``app/layout.tsx`` — inside ``RealtimeProvider``. The socket
|
||||
// itself is stubbed out: a test must not open a real WebSocket.
|
||||
vi.mock("@/lib/ws", () => {
|
||||
const listener = () => () => {};
|
||||
const client = {
|
||||
getStatus: () => "offline" as const,
|
||||
onStatus: listener,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: () => () => {},
|
||||
rpc: vi.fn(),
|
||||
};
|
||||
return { getRealtimeClient: () => client, websocketUrl: () => "ws://localhost/api/ws" };
|
||||
});
|
||||
|
||||
function renderShell() {
|
||||
return render(
|
||||
<RealtimeProvider>
|
||||
<AppShell>
|
||||
<p>بدنه</p>
|
||||
</AppShell>
|
||||
</RealtimeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "package.json"), "utf-8"),
|
||||
) as { version: string };
|
||||
@@ -52,12 +79,12 @@ describe("app version", () => {
|
||||
|
||||
describe("sidebar version badge", () => {
|
||||
it("shows the version in the shell footer", () => {
|
||||
render(<AppShell><p>بدنه</p></AppShell>);
|
||||
renderShell();
|
||||
expect(screen.getByText(APP_VERSION_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the direction explicitly so the RTL shell cannot reorder it", () => {
|
||||
render(<AppShell><p>بدنه</p></AppShell>);
|
||||
renderShell();
|
||||
const badge = screen.getByText(APP_VERSION_LABEL);
|
||||
expect(badge.className).toContain("ltr-inline");
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smart-org",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
|
||||
@@ -40,11 +40,14 @@ const EFFORT_LABELS: Record<ReasoningEffort, string> = {
|
||||
* 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";
|
||||
|
||||
@@ -670,3 +683,99 @@ 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<T = Record<string, unknown>> {
|
||||
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 | null; server_time: string }
|
||||
| { type: "subscribed"; accepted: string[]; rejected: string[] }
|
||||
| { type: "unsubscribed"; channels: string[] }
|
||||
| { type: "pong" }
|
||||
| { type: "error"; message: string }
|
||||
| { 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" }
|
||||
/** 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 {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
step: {
|
||||
id: string;
|
||||
index: number;
|
||||
kind: string;
|
||||
title: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user