docs(contract): publish UI state and ViewModel contract for Task A and B
This commit is contained in:
parent
f171a8069d
commit
35881c4f04
1 changed files with 182 additions and 0 deletions
182
docs/UI_STATE_CONTRACT.md
Normal file
182
docs/UI_STATE_CONTRACT.md
Normal 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.
|
||||||
Loading…
Reference in a new issue