hermes-hub/docs/UI_STATE_CONTRACT.md

182 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.