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

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

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

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

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

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

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

نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` می‌آیند و به این ادغام ربطی
ندارند؛ دست‌نخورده رها شدند.
This commit is contained in:
mjalmoudy94
2026-10-13 18:09:03 +03:30
49 changed files with 4370 additions and 294 deletions
+110 -1
View File
@@ -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;
}