Files
smart-org/packages/contracts/src/index.ts
T
mjalmoudy94 8066e9d055 feat: remove public REST API; route all app calls over WebSocket RPC
The platform no longer exposes a public /api/* REST surface. The original
HTTP route handlers are preserved verbatim as the single source of truth
inside an internal, never-socket-bound FastAPI app (rpc_app), and are now
reached only via RPC-over-WebSocket dispatch.

- realtime router: anonymous connections allowed only to /api/auth/login
  and /api/auth/signup; all other RPCs/subscribes refused until token auth.
- RPC dispatch re-injects the bearer token and X-Request-ID, then forwards
  through httpx.ASGITransport against the internal app (no network hop).
- Public app now serves only /, /health, /health/ready and /api/ws;
  docs/openapi disabled.
- Frontend: apiRequest routes /api/* to ws.rpc(); /health probes keep fetch.
  RealtimeClient gains rpc() with id correlation, timeout and AbortSignal.
- Tests repointed to get_rpc_app(); anonymous-auth behaviour covered.

Result: zero REST on the public surface; 264 backend tests + frontend
typecheck pass.
2026-10-13 13:54:56 +03:30

765 lines
17 KiB
TypeScript

/**
* Shared API contracts.
*
* These types mirror the FastAPI response schemas. They are intentionally
* hand-written and small: the backend is the source of truth, and a drift here
* surfaces immediately as a TypeScript error at the call site.
*/
export type Role = "owner" | "admin" | "manager" | "member" | "viewer";
export type MembershipStatus = "active" | "invited" | "suspended";
/**
* Provider kinds the AI Control Plane can talk to. agy (Google Antigravity)
* is the only one — the platform no longer has a provider picker.
*/
export type ProviderType = "agy";
/** agy reasoning efforts, cheapest first. Mirrors the backend's list. */
export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
export const REASONING_EFFORTS: ReasoningEffort[] = [
"minimal",
"low",
"medium",
"high",
"xhigh",
];
const EFFORT_LABELS: Record<ReasoningEffort, string> = {
minimal: "حداقل",
low: "کم",
medium: "متوسط",
high: "زیاد",
xhigh: "بسیار زیاد",
};
/**
* Reasoning efforts a model may be configured with, or an empty list when the
* 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
* 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") ||
lower.includes("gemini-2.5-pro") ||
lower.includes("gemini-2.5-flash");
return canThink ? [...REASONING_EFFORTS] : [];
}
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";
/**
* One agy account (Google identity). Deliberately credential-free: the console
* manages accounts, never tokens.
*/
export interface AgyAccount {
id: string;
provider_id: string;
name: string;
email: string;
project_id: string;
status: AgyAccountStatus;
cooldown_until: string | null;
is_active: boolean;
last_error: string;
last_used_at: string | null;
created_at: string;
}
/** What a model can do. Routing picks models by required capability. */
export type Capability = "text" | "vision" | "tools" | "reasoning" | "long_context" | "code";
/** Routing intents. Customers never see these names — only the platform owner does. */
export type RoutingMode = "fast" | "cheap" | "balanced" | "reasoning" | "vision" | "coding";
export type TaskStatus =
| "new"
| "triaged"
| "waiting_approval"
| "running"
| "done"
| "failed"
| "cancelled";
export type RunStatus =
| "queued"
| "planning"
| "waiting_approval"
| "running"
| "tool_calling"
| "sandbox_running"
| "completed"
| "failed"
| "cancelled"
| "timed_out";
export type RoleKey =
| "manager"
| "planner"
| "developer"
| "reviewer"
| "tester"
| "researcher"
| "content_creator"
| "support";
export type EmployeeStatus = "active" | "paused" | "archived";
export type PreviewDomainStatus = "active" | "disabled" | "failed";
export type RateUnit =
| "model_input_tokens"
| "model_output_tokens"
| "sandbox_cpu_second"
| "sandbox_memory_gb_second"
| "storage_gb_day"
| "tool_call"
| "premium_employee_run";
export interface ApiErrorBody {
error: {
code: string;
message: string;
details?: unknown;
/**
* Correlates this response with the row the API wrote to its error console
* (and with the `X-Request-ID` response header). Surfacing it lets a user
* quote one string that points at exactly one server-side record.
*/
request_id?: string;
};
}
export interface User {
id: string;
email: string;
name: string;
avatar_url?: string | null;
is_platform_owner: boolean;
}
export interface Organization {
id: string;
name: string;
slug: string;
owner_user_id: string;
}
export interface OrganizationWithRole extends Organization {
role: Role;
}
export interface Member {
id: string;
user_id: string;
email: string;
name: string;
role: Role;
status: MembershipStatus;
created_at: string;
}
export interface Invite {
id: string;
email: string;
role: Role;
expires_at: string;
invite_token: string;
invite_url: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
user: User;
}
export interface MeResponse {
user: User;
organizations: OrganizationWithRole[];
}
export interface Project {
id: string;
organization_id: string;
name: string;
slug: string;
description: string;
default_branch: string;
created_at: string;
}
export interface RepoStatus {
exists: boolean;
private: boolean;
default_branch: string;
branches: string[];
open_pull_requests: number;
}
export interface Repository {
id: string;
provider: string;
owner: string;
repo_name: string;
clone_url: string;
web_url: string;
default_branch: string;
private: boolean;
}
export interface AdminModel {
id: string;
provider_id: string;
internal_name: string;
display_name_for_admin: string;
capabilities: string[];
input_price_per_1m: number;
output_price_per_1m: number;
/** agy reasoning effort ("" = provider default). Owner-only metadata. */
reasoning_effort: string;
is_active: boolean;
}
export interface AdminRoutingPolicy {
id: string;
name: string;
description: string;
mode: string;
is_default: boolean;
is_active: boolean;
}
export interface AdminRateCard {
id: string;
name: string;
unit: RateUnit;
customer_price: number;
internal_cost_estimate: number;
is_active: boolean;
}
export interface AdminOrganization {
id: string;
name: string;
slug: string;
member_count: number;
created_at: string;
}
/** Result of a routing dry-run: which model a mode would pick, and why. */
export interface ModelChoice {
mode: string;
model_id: string;
internal_name: string;
capabilities: string[];
estimated_input_price_per_1m: number;
estimated_output_price_per_1m: number;
}
export interface AdminUsageRow {
organization_id: string;
quantity: number;
total_price: number;
}
export interface PreviewDomain {
id: string;
host: string;
port: number;
status: PreviewDomainStatus;
protected: boolean;
url: string;
}
export interface Employee {
id: string;
organization_id: string;
project_id?: string | null;
template_id?: string | null;
name: string;
role_key: RoleKey;
status: EmployeeStatus;
routing_policy_id?: string | null;
created_at: string;
}
export interface Task {
id: string;
organization_id: string;
project_id?: string | null;
thread_id?: string | null;
title: string;
description: string;
status: TaskStatus;
assigned_employee_id?: string | null;
created_at: string;
updated_at: string;
}
export interface Run {
id: string;
task_id: string;
employee_id?: string | null;
status: RunStatus;
error?: string | null;
started_at?: string | null;
finished_at?: string | null;
created_at: string;
}
export interface Thread {
id: string;
organization_id: string;
project_id?: string | null;
title: string;
created_at: string;
}
export interface Message {
id: string;
thread_id: string;
role: "user" | "assistant" | "system";
content: string;
employee_id?: string | null;
created_at: string;
}
export interface ThreadDetail extends Thread {
messages: Message[];
}
export interface RunStep {
id: string;
index: number;
kind: string;
title: string;
payload_json: Record<string, unknown>;
created_at: string;
}
export interface ToolCall {
id: string;
tool_key: string;
status: string;
requires_approval: boolean;
arguments_json: Record<string, unknown>;
result_json: Record<string, unknown>;
created_at: string;
}
export interface ArtifactDetail extends Artifact {
content_ref: string;
run_id?: string | null;
}
export interface RunDetail extends Run {
steps: RunStep[];
tool_calls: ToolCall[];
artifacts: ArtifactDetail[];
}
export interface TaskDetail extends Task {
runs: Run[];
}
export interface AgentTemplate {
id: string;
name: string;
role_key: RoleKey;
description: string;
default_tools_json: string[];
}
/** A routing policy as a customer may see it — no mode, no model hints. */
export interface RoutingChoice {
id: string;
name: string;
description: string;
}
export interface PromptVersion {
id: string;
version: number;
content: string;
created_at: string;
}
export interface ToolPermission {
tool_key: string;
allowed: boolean;
}
/** One entry in the tool catalog an employee can be granted. */
export interface ToolSpec {
key: string;
label: string;
description: string;
requires_approval: boolean;
destructive: boolean;
category: string;
}
export interface EmployeeDetail extends Employee {
prompt_versions: PromptVersion[];
tool_permissions: ToolPermission[];
open_task_count: number;
}
export interface PostMessageResponse {
user_message: Message;
assistant_message: Message;
}
export interface Artifact {
id: string;
name: string;
kind: string;
size_bytes: number;
created_at: string;
}
export interface BillingSummary {
balance: number;
currency: string;
low_balance: boolean;
low_balance_threshold: number;
total_spend: number;
by_unit: { unit: RateUnit; quantity: number; total_price: number }[];
by_employee: Record<string, number>;
by_project: Record<string, number>;
}
export interface UsageEvent {
id: string;
unit: RateUnit;
quantity: number;
unit_price: number;
total_price: number;
project_id?: string | null;
employee_id?: string | null;
run_id?: string | null;
created_at: string;
}
export interface Invoice {
id: string;
amount: number;
currency: string;
status: string;
period_start?: string | null;
period_end?: string | null;
created_at: string;
}
export interface Payment {
id: string;
amount: number;
currency: string;
status: string;
provider: string;
created_at: string;
}
export interface Wallet {
balance: number;
currency: string;
low_balance: boolean;
}
export interface AuditEvent {
id: string;
action: string;
actor_user_id?: string | null;
target_type: string;
target_id: string;
created_at: string;
}
// --- sandbox ----------------------------------------------------------------
export type NetworkPolicy = "deny_all" | "allowlist" | "open";
export interface SandboxTemplate {
id: string;
name: string;
provider: string;
image: string;
description: string;
default_cpu: number;
default_memory_mb: number;
default_disk_mb: number;
}
export interface SandboxWorkspace {
id: string;
project_id: string;
template_id: string;
provider: string;
status: string;
created_at: string;
}
export interface SandboxSession {
id: string;
workspace_id: string;
status: string;
started_at: string;
last_active_at: string;
}
export interface SandboxCommandRun {
id: string;
command: string;
exit_code: number | null;
stdout: string;
stderr: string;
duration_ms: number;
truncated: boolean;
created_at: string;
}
export interface SandboxFileRead {
path: string;
content: string;
}
export interface SandboxSnapshot {
id: string;
name: string;
created_at: string;
}
export interface SandboxPreviewUrl {
port: number;
url: string;
}
/**
* Effective limits are resolved server-side (project -> organization ->
* platform) and clamped. The UI only ever displays them.
*/
export interface SandboxResourceLimit {
cpu: number;
memory_mb: number;
disk_mb: number;
timeout_seconds: number;
max_output_bytes: number;
network_policy: NetworkPolicy;
}
export interface HealthResponse {
status: string;
app: string;
env: string;
}
export interface ReadinessResponse {
status: string;
checks: {
database: string;
model_provider: string;
adapters: Record<string, string>;
};
}
/* -------------------------------------------------------------------------- */
/* Error console (platform owner) */
/* -------------------------------------------------------------------------- */
export type ErrorSeverity = "warning" | "error" | "critical";
export type ErrorSource = "http" | "worker" | "startup";
export type ErrorStatusFilter = "open" | "resolved";
/**
* One persisted failure. Deliberately wide: a record that lacks the request
* path or the exception class forces whoever is debugging to reproduce the bug
* just to learn what it was.
*/
export interface ErrorEvent {
id: string;
kind: string;
message: string;
stack: string;
severity: ErrorSeverity;
source: ErrorSource;
error_type: string;
fingerprint: string;
status_code: number | null;
method: string;
path: string;
organization_id: string | null;
user_id: string | null;
run_id: string | null;
request_id: string;
ip: string;
user_agent: string;
metadata_json: Record<string, unknown>;
resolved_at: string | null;
resolved_note: string;
created_at: string;
}
/** Every occurrence of one distinct bug, collapsed into a single row. */
export interface ErrorGroup {
fingerprint: string;
occurrences: number;
open_count: number;
first_seen: string;
last_seen: string;
kind: string;
error_type: string;
severity: ErrorSeverity;
source: ErrorSource;
path: string;
sample_message: string;
}
export interface ErrorSummary {
open_by_severity: Record<string, number>;
total_open: number;
}
export interface ErrorListResponse {
items: ErrorEvent[];
total: number;
limit: number;
offset: number;
}
export interface ResolveErrorResponse {
fingerprint: string;
resolved: number;
}
export interface ErrorQuery {
status?: ErrorStatusFilter;
severity?: ErrorSeverity;
source?: ErrorSource;
organization_id?: string;
search?: string;
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;
}