docs(contract): publish UI state and ViewModel contract for Task A and B

This commit is contained in:
Hermes Team 2026-08-20 23:44:07 +07:00
parent f171a8069d
commit 35881c4f04

182
docs/UI_STATE_CONTRACT.md Normal file
View file

@ -0,0 +1,182 @@
# Hermes Hub — UI State & ViewModel Contract
**Document Version:** 1.0.0
**Date:** 2026-08-21
**Status:** Canonical Interface Specification for UI (Codex Assignment B) & State Layer (Antigravity Assignment A)
**Scope:** `src/antigravity_provider/router/state_store.py`, `unified_health.py`, `account_identity.py`, `event_bus.py`
---
## 1. Executive Architecture & Invariants
1. **Single Source of Truth (`HubSnapshot`):** The entire UI layer reads state exclusively from the immutable `HubSnapshot` supplied by `HubStateStore.get().get_snapshot()` or emitted via `EventBus`. Views MUST NOT call scanning/probing methods (e.g. `scan_all()`).
2. **Delta Updates via `EventBus`:** Incremental updates (quota shifts, single account mutations, route swaps) dispatch targeted typed events on `EventBus.get()`.
3. **Data Truthfulness Invariant:** Unverified or offline metrics MUST report `is_estimated = True` with source `"baseline"` or `"estimated"`, and percentages as `None` (UI displays «Доступна» / «(оценка)»). Fabricated percentages or false `*_api` source labels are strictly forbidden.
---
## 2. Core Snapshot Model: `HubSnapshot`
Immutable snapshot (`@dataclass(frozen=True)`) representing the state of Hermes Hub at generation `generation`.
| Field | Type | Description | Real / Simulated |
|---|---|---|---|
| `generation` | `int` | Monotonically increasing generation number (increments on every snapshot update). | **Real** |
| `timestamp` | `float` | UNIX epoch timestamp when snapshot was created (`time.time()`). | **Real** |
| `profiles_by_provider` | `Dict[str, List[ProfileViewModel]]` | Profiles grouped by provider identifier (`"antigravity"`, `"openai-codex"`, `"opencode-go"`, `"claude"`, `"grok"`). | **Real** |
| `all_profiles` | `Dict[str, ProfileViewModel]` | Flat lookup map of all profiles indexed by `profile_id`. | **Real** |
| `readiness` | `SystemReadiness` | Aggregated system readiness, role coverage metrics, and warnings. | **Real** |
| `agents` | `List[AgentViewModel]` | List of logical agent roles and their active routing status. | **Real** |
| `providers` | `List[ProviderSummary]` | Summary status per provider for quick overview cards. | **Real** |
| `routing` | `Dict[str, RolePipeline]` | Role pipelines mapping `role_id` to failover nodes. | **Real** |
| `quotas` | `Dict[str, QuotaSnapshot]` | Map of `profile_id` -> `QuotaSnapshot`. | **Real** |
| `metrics` | `Dict[str, Any]` | Internal metrics (e.g. `refresh_runs_total`, `refresh_failures_total`). | **Real** |
| `is_stale` | `bool` | True if background refresh is overdue (> 300s). | **Real** |
### Helper Methods
- `get_profile(profile_id: str) -> Optional[ProfileViewModel]`
- `get_provider_profiles(provider: str) -> List[ProfileViewModel]`
- `get_role_pipeline(role_id: str) -> Optional[RolePipeline]`
---
## 3. Account View Model: `ProfileViewModel`
Model representing an individual account/slot card in UI views.
| Field | Type | Nullable / Optional | Description & Values |
|---|---|---|---|
| `profile_id` | `str` | No | Unique profile identifier (e.g. `"ag-w1"`, `"codex-slot-1"`). |
| `display_name` | `str` | No | User-facing display title (e.g. `"Antigravity Slot 1"`). |
| `account_identity` | `str` | No | Primary user identity (email, account ID, or profile ID). |
| `provider` | `str` | No | Provider ID (`"antigravity"`, `"openai-codex"`, `"opencode-go"`, `"claude"`, `"grok"`). |
| `provider_display_name` | `str` | No | Formatted provider name (e.g. `"Google Antigravity"`, `"OpenAI Codex"`). |
| `assigned_roles` | `List[str]` | No | List of role names assigned to this profile. |
| `primary_role` | `Optional[str]` | Yes | Primary assigned role name (or `None` if unassigned / spare). |
| `is_main_account` | `bool` | No | True if designated as Main Account for general execution. |
| `is_main_orchestrator` | `bool` | No | True if designated as Orchestrator profile. |
| `auth_state` | `str` | No | `"AUTHENTICATED"` \| `"AUTH_REQUIRED"` \| `"AUTH_EXPIRED"` \| `"UNCONFIGURED"`. |
| `health_state` | `str` | No | `"healthy"` \| `"warning"` \| `"exhausted"` \| `"rate_limited"` \| `"cooldown"` \| `"auth_required"` \| `"disabled"` \| `"cold_spare"` \| `"unhealthy"` \| `"not_configured"`. |
| `health_label_ru` | `str` | No | Localized Russian status text (e.g. `"Готов"`, `"Исчерпан"`, `"Ограничение"`). |
| `model_states` | `Dict[str, ModelFamilyHealth]` | No | Per-family health records (e.g. `{"gemini": ModelFamilyHealth(...)}`). |
| `cooldown_remaining_sec` | `int` | No | Seconds until cooldown/rate-limit expires (`0` if healthy). |
| `last_checked_at` | `Optional[str]` | Yes | Formatted time string (e.g. `"15:42:10"` or `"недавно"`). |
| `enabled` | `bool` | No | True if slot is enabled in `router_profiles.yaml`. |
| `is_cold_spare` | `bool` | No | True if authenticated but unassigned to any active role chain. |
| `is_empty_slot` | `bool` | No | True if unauthenticated placeholder slot. |
| `email` | `str` | No | Email parsed from JWT/API (empty string if unavailable). |
| `plan` | `str` | No | Localized plan string (e.g. `"Тариф: MAX"`, `"Тариф: PRO"`, `"Тариф: неизвестен"`). |
| `plan_code` | `str` | No | Normalized plan code (`"PRO"`, `"PLUS"`, `"MAX"`, `"SUPERGROK"`, `"UNKNOWN"`). |
| `quota_snapshot` | `Optional[QuotaSnapshot]` | Yes | Associated quota snapshot object. |
| `preferred_models` | `List[str]` | No | List of configured models for this profile. |
---
## 4. Quota Models: `QuotaSnapshot` & `QuotaBucket`
### `QuotaSnapshot` Schema
- `account_id: str` — Profile or Account ID
- `provider: str` — Provider ID
- `buckets: List[QuotaBucket]` — 1 to 4 limit buckets
- `fetched_at: datetime` — UTC timestamp of measurement
- `stale_after_seconds: int` — Expiry window (default 300s)
- `source: str``"baseline"` \| `"estimated"` \| `"runtime_event"` \| `"provider_api"`
- `is_estimated: bool`**True** if offline baseline / heuristic, **False** if verified live API
- `freshness_label() -> str` — e.g. `"Обновлено: только что"`, `"Обновлено: 5 мин назад"`
### `QuotaBucket` Schema
- `id: str` — e.g. `"antigravity.claude.5h"`, `"codex.weekly"`, `"grok.frequent_tasks"`
- `display_name: str` — e.g. `"5h"`, `"Weekly"`, `"Задачи"`, `"Запросы"`
- `model_family: Optional[str]` — Model family bounded by this bucket (`"gemini"`, `"claude"`, `"gpt"`, `"grok"`, `"opencode"`)
- `used_percent: Optional[float]``0.0 .. 100.0` or `None` if unmeasured
- `remaining_percent: Optional[float]``0.0 .. 100.0` or `None` if unmeasured
- `used_absolute: Optional[int]` — Absolute units used (if reported by provider API)
- `remaining_absolute: Optional[int]` — Absolute units remaining
- `limit_absolute: Optional[int]` — Absolute maximum limit
- `reset_at: Optional[datetime]` — UTC reset timestamp
- `reset_in_seconds: Optional[int]` — Seconds until quota reset
- `period: Optional[str]``"5h"`, `"7d"`, `"30d"`, `"sliding"`
- `status: str``"healthy"`, `"warning"`, `"exhausted"`, `"unknown"`
- `formatted_remaining() -> str` — e.g. `"Доступна"`, `"Осталось 85%"`, `"150/1000"`
- `formatted_reset() -> Optional[str]` — e.g. `"Сброс через 1ч 45м"`, `None`
---
## 5. Actual Provider Quota Breakdown (Live vs Estimated)
| Provider | Real Quota APIs Available? | Buckets Populated | `source` | `is_estimated` | Notes |
|---|---|---|---|---|---|
| **Google Antigravity** | Partial (via CLI runtime 429 reset parsing) | `antigravity.claude.5h`, `antigravity.claude.weekly`, `antigravity.gemini` | `"baseline"` or `"runtime_event"` | `True` (baseline) / `False` (on 429 event) | Baseline shows «Доступна (оценка)». On 429, parses exact reset duration. |
| **OpenAI Codex** | Device flow / local token | `codex.primary.weekly` | `"baseline"` or `"runtime_event"` | `True` (baseline) / `False` (on 429 event) | Baseline shows «Доступна (оценка)». |
| **xAI Grok** | Device flow / token | `grok.frequent_tasks`, `grok.daily_quota` | `"baseline"` or `"runtime_event"` | `True` (baseline) / `False` (on 429 event) | Baseline shows «Доступна (оценка)». |
| **Anthropic Claude** | Token / PKCE | `claude.session.5h`, `claude.weekly` | `"baseline"` or `"runtime_event"` | `True` (baseline) / `False` (on 429 event) | Baseline shows «Доступна (оценка)». |
| **OpenCode Go** | Local CLI / API key | `opencode.tasks` | `"baseline"` | `True` | Baseline shows «Доступна (оценка)». |
---
## 6. Routing & System ViewModels
### `SystemReadiness`
- `state: str``"healthy"` \| `"limited"` \| `"degraded"` \| `"critical"`
- `title_ru: str` — Summary title (e.g. `"Система готова к работе"`)
- `summary_ru: str` — Explanatory status text
- `roles_ready_count: int`, `total_roles: int`
- `accounts_connected_count: int`, `total_accounts: int`
- `providers_ready_count: int`, `total_providers: int`
- `warnings: List[str]` — Critical warning strings for banner display
### `AgentViewModel`
- `role_id: str` — Logical role (e.g. `"coder-primary"`, `"reviewer"`)
- `role_name_ru: str`, `role_description_ru: str`
- `assigned_profile_id: Optional[str]`, `assigned_display_name: Optional[str]`
- `provider: str`, `provider_display_name: str`, `model: str`
- `routing_position: str``"Primary"`, `"Fallback 1"`, `"Fallback 2"`
- `status: str``"healthy"`, `"exhausted"`, `"auth_required"`, `"unconfigured"`
- `is_active: bool`, `is_main_orchestrator: bool`, `cooldown_remaining_sec: int`
### `RolePipeline` & `PipelineNode`
- `RolePipeline`: `role_id`, `role_name_ru`, `default_model`, `max_failover`, `session_affinity`, `active_profile_id`, `nodes: List[PipelineNode]`
- `PipelineNode`: `profile_id`, `display_name`, `provider`, `model`, `status`, `status_label_ru`, `is_active: bool`, `cooldown_remaining_sec: int`
### `ProviderSummary`
- `provider_id: str`, `provider_name: str`, `total_slots: int`, `connected_count: int`, `online_count: int`
- `auth_required_count: int`, `quota_exhausted_count: int`, `cold_spare_count: int`
- `discovered_models: List[str]`, `last_refresh_at: str`
---
## 7. EventBus Event Catalog & Payloads
Subscribers register with `EventBus.get().subscribe(event_name, callback)`:
| Event Constant | Name String | Payload Schema | Trigger Condition |
|---|---|---|---|
| `EVENT_ACCOUNT_UPDATED` | `"ACCOUNT_UPDATED"` | `{"provider": str, "profile_id": str, "profile": ProfileViewModel}` | Single profile auth, role, or state changed |
| `EVENT_ACCOUNT_ADDED` | `"ACCOUNT_ADDED"` | `{"provider": str, "profile_id": str}` | New account authorized / connected |
| `EVENT_ACCOUNT_REMOVED` | `"ACCOUNT_REMOVED"` | `{"provider": str, "profile_id": str}` | Account removed / disconnected |
| `EVENT_ACCOUNT_AUTH_CHANGED` | `"ACCOUNT_AUTH_CHANGED"` | `{"provider": str, "profile_id": str, "auth_state": str}` | Token expired or auth invalidated |
| `EVENT_QUOTA_UPDATED` | `"QUOTA_UPDATED"` | `{"provider": str, "profile_id": str, "snapshot": QuotaSnapshot}` | Single account quota changed (429 or refresh) |
| `EVENT_QUOTA_STALE` | `"QUOTA_STALE"` | `{"provider": str, "profile_id": str}` | Quota snapshot expired |
| `EVENT_PROVIDER_HEALTH_CHANGED` | `"PROVIDER_HEALTH_CHANGED"` | `{"provider": str, "summary": ProviderSummary}` | Aggregate provider health shift |
| `EVENT_ROUTING_UPDATED` | `"ROUTING_UPDATED"` | `{"role_id": str, "pipeline": RolePipeline}` | Active role routing modified |
| `EVENT_ROUTING_SLOT_UPDATED` | `"ROUTING_SLOT_UPDATED"` | `{"role_id": str, "node": PipelineNode}` | Failover occurred to backup node |
| `EVENT_AGENT_UPDATED` | `"AGENT_UPDATED"` | `{"role_id": str, "agent": AgentViewModel}` | Agent role status changed |
| `EVENT_SYSTEM_READINESS_CHANGED`| `"SYSTEM_READINESS_CHANGED"`| `{"readiness": SystemReadiness}` | Global readiness level shifted |
| `EVENT_REFRESH_STARTED` | `"REFRESH_STARTED"` | `{"scope": str, "provider": Optional[str], "seq": int}` | Background refresh task started |
| `EVENT_REFRESH_COMPLETED` | `"REFRESH_COMPLETED"` | `{"scope": str, "provider": Optional[str], "seq": int}` | Background refresh task succeeded |
| `EVENT_REFRESH_FAILED` | `"REFRESH_FAILED"` | `{"scope": str, "provider": Optional[str], "error": str, "seq": int}` | Background refresh task failed |
---
## 8. Backend Gaps (Known Limitations & Gaps)
To maintain complete transparency and prevent UI fabrication:
1. **Live Quota Metrics:**
- OpenAI, xAI, and Claude do not offer public standard REST endpoints for real-time per-second token balances on OAuth device tokens without dedicated organization admin keys.
- Consequently, initial state reports `source="baseline"`, `is_estimated=True`, and percentages as `None`.
- Quotas transition to `status="exhausted"`, `source="runtime_event"`, `is_estimated=False` with exact reset timers **only upon encountering real provider 429 responses** during runtime execution.
2. **Subscription Expiry Timestamps:**
- `SubscriptionPlan.expires_at` and `renews_at` are populated when present in JWT claims (e.g. Google CloudCode / Anthropic JWT claims); otherwise they are `None`.
3. **Model Discovery:**
- Static default fallback models are provided when offline or before the first CLI invocation.