feat(ui): wire state contract v1.1 fields

This commit is contained in:
Hermes Team 2026-08-21 09:04:53 +07:00
parent 9ffc815ed5
commit 95d80268f4
7 changed files with 429 additions and 10 deletions

View file

@ -0,0 +1,69 @@
# Hermes Hub — подключение UI-контракта v1.1
Дата: 2026-08-21
Ветка: `codex/contract-v11`
BASE_SHA: `9ffc815ed59f7fd364e176ffef20bf29d545bc46`
FINAL_SHA: `git rev-parse codex/contract-v11` в момент handoff. Точный SHA указан в итоговом сообщении, поскольку commit не может содержать собственный SHA.
## Изменённые файлы
- `src/antigravity_provider/router/hermes_hub_app.py`
- `src/antigravity_provider/router/ui/components.py`
- `src/antigravity_provider/router/ui/views/dashboard_view.py`
- `src/antigravity_provider/router/ui/views/routing_view.py`
- `src/antigravity_provider/router/ui/views/team_view.py`
- `tests/test_ui_contract_v11.py`
- `CODEX_CONTRACT_V11_REPORT.md`
Файлы backend/state/adapters, installer, scripts, config, legacy и чужие тесты не изменялись.
## Что подключено
- `ProfileViewModel.plan_code` и `plan_source`: подтверждённый провайдером тариф показан акцентным `PlanBadge`; `inferred` явно помечен как «выведено»; `unknown` скрыт.
- `QuotaSnapshot.unavailable_reason` и совместимый fallback на `QuotaBucket.unavailable_reason`: причина отсутствия данных показана под корзиной.
- `QuotaBar` и `QuotaBucketWidget`: `None` показан как «Н/Д» нейтральным цветом, реальный `0%` — как исчерпанная квота красным цветом.
- `AgentViewModel.active_quota_status`, `active_quota_label` и `session_id`: активная квота и сессия видны в карточке агента.
- `PipelineNode.quota_status`, `failover_reason` и `account_identity`: состояние квоты и реальная причина показаны у узла, с которого ушёл трафик.
- `HubSnapshot.seq` и `is_stale`: номер snapshot и предупреждение об устаревших данных показаны на Dashboard и в общей строке состояния.
- Устранено обращение Dashboard/маршрутизации к отсутствующему `PipelineNode.provider_display_name`; используется контрактное поле `provider`.
## Проверки
### Без UI-зависимостей
```powershell
uv run --isolated --no-project --with pytest --with pyyaml --with pydantic --with requests --with httpx python -m pytest -q
```
Результат: `169 passed, 22 skipped, 3 deselected in 8.87s`. `customtkinter`, Pillow и psutil намеренно отсутствуют, UI-тесты пропущены.
### С UI-зависимостями
```powershell
uv run --extra dev python -m pytest -q
```
Результат: `216 passed, 2 skipped, 3 deselected in 24.22s`, включая `5 passed` в `tests/test_ui_contract_v11.py`.
### Ruff и release gate
```powershell
uv run ruff check .
uv run --extra dev python scripts/release_gate.py
```
Результат: Ruff — `All checks passed!`; release gate — `PASSED`. Публичный manifest доступен, package URL по-прежнему имеет статус pending upload/404, как и до B3.
## Что осталось
- Все поля, перечисленные в задании B3, подключены. Сбор данных в UI не добавлялся.
- Тег `v0.1.1`, release и manifest не создавались и не изменялись.
## Backend gaps
1. В фактической модели v1.1 `unavailable_reason` находится на `QuotaSnapshot`, а не на `QuotaBucket`, хотя таблица задания называет `QuotaBucket`. UI поддерживает оба расположения без собственного сбора данных.
2. Baseline-корзины провайдеров не содержат числовых лимитов: до provider claim/runtime event UI честно показывает «Н/Д», а не синтетический процент.
3. Live release package остаётся внешней задачей: release gate сообщает `PACKAGE_LIVE=False (Pending Upload 404)`.

View file

@ -437,8 +437,9 @@ class HermesHubApp(ctk.CTk):
snap = HubStateStore.get().get_snapshot()
readiness = snapshot_or_readiness
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
self.status_left.configure(
text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
text=f"{freshness} | Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
)
r_color = (

View file

@ -600,7 +600,25 @@ class _TextBadge(ctk.CTkFrame):
class PlanBadge(_TextBadge):
"""Provider plan badge. Unknown/empty plans should not instantiate it."""
"""Provider plan badge that makes provider truth distinct from inference."""
TRUSTED_SOURCES = {"provider_api", "jwt_claim", "provider_auth"}
def set_plan(self, plan_code: str, plan_source: str) -> bool:
"""Update the badge and return whether it should be visible."""
code = str(plan_code or "UNKNOWN").strip().upper()
source = str(plan_source or "unknown").strip().lower()
if code == "UNKNOWN" or source == "unknown":
return False
if source in self.TRUSTED_SOURCES:
self.set_text(f"Тариф {code}")
self.configure(border_color=Theme.BORDER_ACCENT, fg_color=Theme.SURFACE_MUTED)
self.label.configure(text_color=Theme.TEXT_ACCENT)
else:
self.set_text(f"Тариф {code} • выведено")
self.configure(border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED)
self.label.configure(text_color=Theme.TEXT_MUTED)
return True
class QuotaBar(ctk.CTkFrame):
@ -668,7 +686,15 @@ class QuotaBucketWidget(HubCard):
self.bar = QuotaBar(self, label="Остаток")
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X)
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM))
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0))
self.unavailable_reason = ctk.CTkLabel(
self,
text="",
font=Theme.font_micro(),
text_color=Theme.COLOR_CAUTION,
wraplength=320,
justify="left",
)
self.update_bucket(label, remaining_ratio, reset_text, is_estimated)
def update_bucket(
@ -678,11 +704,19 @@ class QuotaBucketWidget(HubCard):
reset_text: str = "",
is_estimated: bool = False,
detail: Optional[str] = None,
unavailable_reason: Optional[str] = None,
) -> None:
suffix = " • оценка" if is_estimated else ""
self.title.configure(text=f"{label}{suffix}")
self.bar.set_value(remaining_ratio, detail or ("Н" if remaining_ratio is None else None))
self.reset.configure(text=reset_text or "Сброс: Н")
if unavailable_reason:
self.reset.pack_configure(pady=(Theme.SPACE_XS, 0))
self.unavailable_reason.configure(text=f"Недоступно: {unavailable_reason}")
self.unavailable_reason.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM))
else:
self.unavailable_reason.pack_forget()
self.reset.pack_configure(pady=(Theme.SPACE_XS, Theme.SPACE_SM))
class SearchField(HubEntry):
@ -764,13 +798,38 @@ class RouteTargetWidget(HubCard):
self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0))
self.subtitle = ctk.CTkLabel(self, text=subtitle, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_SM))
self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.quota = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.quota.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.failover = ctk.CTkLabel(
self, text="", font=Theme.font_micro(), text_color=Theme.COLOR_CAUTION, wraplength=260, justify="left"
)
self.update_target(rank, title, subtitle, status)
def update_target(self, rank: str, title: str, subtitle: str, status: str = "unknown") -> None:
def update_target(
self,
rank: str,
title: str,
subtitle: str,
status: str = "unknown",
quota_status: str = "unknown",
failover_reason: Optional[str] = None,
) -> None:
self.rank.configure(text=rank)
self.title.configure(text=title)
self.subtitle.configure(text=subtitle)
quota_labels = {
"healthy": "Квота: доступна",
"warning": "Квота: заканчивается",
"exhausted": "Квота: исчерпана",
}
self.quota.configure(text=quota_labels.get(quota_status, "Квота: Н"))
if failover_reason:
self.failover.configure(text=f"Переключено: {failover_reason}")
self.failover.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM))
else:
self.failover.pack_forget()
self.quota.pack_configure(pady=(0, Theme.SPACE_SM))
self.set_status(status)
def set_status(self, status: str) -> None:
@ -811,6 +870,7 @@ class AccountCardWidget(HubCard):
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.meta.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0))
self.plan_badge = PlanBadge(self, text="")
self.status = StatusBadge(self, status)
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
@ -874,6 +934,17 @@ class AccountCardWidget(HubCard):
self.provider.configure(text=profile.provider_display_name or profile.provider)
roles = ", ".join(getattr(profile, "assigned_roles", []) or []) or "Роль: Н"
self.meta.configure(text=f"{profile.display_name}{roles}")
if self.plan_badge.set_plan(
getattr(profile, "plan_code", "UNKNOWN"), getattr(profile, "plan_source", "unknown")
):
self.plan_badge.pack(
anchor="w",
padx=Theme.CARD_PAD_X,
pady=(Theme.SPACE_SM, 0),
before=self.status,
)
else:
self.plan_badge.pack_forget()
self.status.set_status(profile.health_state, getattr(profile, "health_label_ru", None))
self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER)
@ -904,6 +975,7 @@ class AccountCardWidget(HubCard):
reset or "Сброс: Н",
estimated,
detail,
getattr(bucket, "unavailable_reason", None) or getattr(snapshot, "unavailable_reason", None),
)
for key in list(self._quota_widgets):

View file

@ -58,6 +58,15 @@ class DashboardView(ctk.CTkFrame):
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.snapshot_freshness = ctk.CTkLabel(
self.scroll,
text="Snapshot: Н",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
anchor="w",
)
self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_SM))
metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
metrics.pack(fill="x", pady=(0, Theme.SECTION_GAP))
for column in range(4):
@ -110,6 +119,16 @@ class DashboardView(ctk.CTkFrame):
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
if snapshot.is_stale:
self.snapshot_freshness.configure(
text=f"⚠ Snapshot #{snapshot.seq}: данные устарели",
text_color=Theme.STATUS_WARNING,
)
else:
self.snapshot_freshness.configure(
text=f"Snapshot #{snapshot.seq}: актуальные данные",
text_color=Theme.TEXT_MUTED,
)
readiness = snapshot.readiness
self.system_metric.val_label.configure(text=readiness.title_ru)
self.system_metric.sub_label.configure(text=readiness.summary_ru)
@ -130,7 +149,7 @@ class DashboardView(ctk.CTkFrame):
route_rows = {}
for role_id, pipeline in snapshot.routing.items():
active = next((node for node in pipeline.nodes if node.is_active), None)
detail = f"{active.provider_display_name}{active.model}" if active else "Активный узел: Н"
detail = f"{active.provider}{active.model}" if active else "Активный узел: Н"
route_rows[role_id] = (pipeline.role_name_ru, detail, "healthy" if active else "warning")
self._sync_rows(self.routes_card, self._route_rows, route_rows)

View file

@ -48,17 +48,30 @@ class RoutingRoleWidget(HubCard):
self._nodes.pop(profile_id).destroy()
for index, node in enumerate(pipeline.nodes):
rank = "Основной" if index == 0 else f"Резерв {index}"
subtitle = f"{node.provider_display_name}{node.model}"
identity = f"{node.account_identity}" if node.account_identity else ""
subtitle = f"{node.provider}{node.model}{identity}"
widget = self._nodes.get(node.profile_id)
if widget is None:
widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle)
self._nodes[node.profile_id] = widget
widget.update_target(rank, node.display_name, subtitle, "active" if node.is_active else node.status)
widget.update_target(
rank,
node.display_name,
subtitle,
"active" if node.is_active else node.status,
node.quota_status,
node.failover_reason,
)
widget.grid(row=0, column=index, padx=Theme.SPACE_XS, pady=Theme.SPACE_SM, sticky="nsew")
self.chain.grid_columnconfigure(index, weight=1)
active = next((node for node in pipeline.nodes if node.is_active), None)
reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason]
self.footer.configure(
text=(f"Активен: {active.display_name} • причина переключения: Н" if active else "Активный узел: Н")
text=(
f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'Н'}"
if active
else "Активный узел: Н"
)
)

View file

@ -76,6 +76,11 @@ class AgentCardWidget(HubCard):
)
self.identity_lbl.pack(anchor="w")
self.quota_lbl = ctk.CTkLabel(
self.line4, text="Квота: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0))
# ── Line 5: Role Tag Pills ──
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
self.line5.pack(fill="x", padx=14, pady=(4, 6))
@ -161,8 +166,21 @@ class AgentCardWidget(HubCard):
else:
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
# AgentViewModel has account identity, but no active-session or quota fields.
self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н")
quota_color = (
Theme.STATUS_HEALTHY
if a.active_quota_status == "healthy"
else (
Theme.STATUS_WARNING
if a.active_quota_status == "warning"
else Theme.STATUS_ERROR
if a.active_quota_status == "exhausted"
else Theme.TEXT_MUTED
)
)
quota_label = a.active_quota_label or "Н"
session = f" • сессия {a.session_id}" if a.session_id else ""
self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color)
# Pills
self.pill1_lbl.configure(text=a.role_id)

View file

@ -0,0 +1,227 @@
"""Acceptance coverage for UI state contract v1.1 fields."""
from __future__ import annotations
import time
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import AccountCardWidget, QuotaBucketWidget
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.routing_view import RoutingRoleWidget
from antigravity_provider.router.ui.views.team_view import AgentCardWidget
from antigravity_provider.router.unified_health import (
AgentViewModel,
PipelineNode,
ProfileViewModel,
RolePipeline,
SystemReadiness,
)
@pytest.fixture(scope="module")
def ui_root():
root = ctk.CTk()
root.withdraw()
yield root
root.destroy()
def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> ProfileViewModel:
return ProfileViewModel(
profile_id="account-1",
display_name="Primary",
account_identity="user@example.test",
provider="antigravity",
provider_display_name="Google Antigravity",
assigned_roles=["coder-primary"],
primary_role="coder-primary",
is_main_account=True,
is_main_orchestrator=False,
auth_state="AUTHENTICATED",
health_state="healthy",
health_label_ru="Работает",
model_states={},
cooldown_remaining_sec=0,
last_checked_at="12:00:00",
enabled=True,
is_cold_spare=False,
is_empty_slot=False,
plan_code=plan_code,
plan_source=plan_source,
)
def _readiness() -> SystemReadiness:
return SystemReadiness(
state="healthy",
title_ru="Система готова",
summary_ru="Все роли доступны",
roles_ready_count=1,
total_roles=1,
accounts_connected_count=1,
total_accounts=1,
providers_ready_count=1,
total_providers=1,
)
@pytest.mark.ui
def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None:
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
try:
card.pack()
card.update_account(_profile("PRO", "provider_api"))
ui_root.update_idletasks()
assert card.plan_badge.label.cget("text") == "Тариф PRO"
assert card.plan_badge.winfo_manager() == "pack"
card.update_account(_profile("PRO", "inferred"))
ui_root.update_idletasks()
assert card.plan_badge.label.cget("text") == "Тариф PRO • выведено"
assert card.plan_badge.label.cget("text_color") == Theme.TEXT_MUTED
card.update_account(_profile("UNKNOWN", "unknown"))
ui_root.update_idletasks()
assert card.plan_badge.winfo_manager() == ""
finally:
card.destroy()
@pytest.mark.ui
def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None:
widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h")
try:
widget.pack()
widget.update_bucket("Claude 5h", None, unavailable_reason="Провайдер не вернул лимит")
ui_root.update_idletasks()
assert widget.bar.detail.cget("text") == "Н"
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEUTRAL
assert "Провайдер не вернул лимит" in widget.unavailable_reason.cget("text")
widget.update_bucket("Claude 5h", 0.0)
ui_root.update_idletasks()
assert widget.bar.detail.cget("text") == "0%"
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEGATIVE
assert widget.unavailable_reason.winfo_manager() == ""
finally:
widget.destroy()
@pytest.mark.ui
def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None:
agent = AgentViewModel(
role_id="coder-primary",
role_name_ru="Кодер 1",
role_description_ru="Основной кодер",
assigned_profile_id="account-2",
assigned_display_name="Reserve",
provider="codex",
provider_display_name="OpenAI Codex",
model="gpt-5",
account_identity="reserve@example.test",
routing_position="Fallback 1",
status="healthy",
status_label_ru="Работает",
is_active=True,
is_main_orchestrator=False,
session_id="session-42",
active_quota_status="warning",
active_quota_label="Осталось 12%",
)
team_card = AgentCardWidget(ui_root)
pipeline = RolePipeline(
role_id="coder-primary",
role_name_ru="Кодер 1",
default_model="gpt-5",
max_failover=2,
session_affinity=True,
active_profile_id="account-2",
nodes=[
PipelineNode(
profile_id="account-1",
display_name="Primary",
provider="Google Antigravity",
model="gemini-2.5-pro",
status="quota_exhausted",
status_label_ru="Исчерпан",
is_active=False,
account_identity="primary@example.test",
quota_status="exhausted",
failover_reason="Исчерпана квота (429)",
),
PipelineNode(
profile_id="account-2",
display_name="Reserve",
provider="OpenAI Codex",
model="gpt-5",
status="healthy",
status_label_ru="Работает",
is_active=True,
account_identity="reserve@example.test",
quota_status="warning",
),
],
)
route = RoutingRoleWidget(ui_root, pipeline)
try:
team_card.pack()
route.pack()
team_card.update_agent(agent)
route.update_from_pipeline(pipeline)
ui_root.update_idletasks()
assert "Осталось 12%" in team_card.quota_lbl.cget("text")
assert "session-42" in team_card.quota_lbl.cget("text")
assert "Исчерпана квота (429)" in route._nodes["account-1"].failover.cget("text")
assert route._nodes["account-2"].failover.cget("text") == ""
assert "Квота: исчерпана" == route._nodes["account-1"].quota.cget("text")
finally:
route.destroy()
team_card.destroy()
@pytest.mark.ui
def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
snapshot = HubSnapshot(
generation=7,
seq=11,
timestamp=time.time() - 301,
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[],
providers=[],
routing={},
quotas={},
is_stale=True,
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
label = view.snapshot_freshness.cget("text")
assert "#11" in label
assert "устарели" in label
assert view.snapshot_freshness.cget("text_color") == Theme.STATUS_WARNING
finally:
view.destroy()
@pytest.mark.unit
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
snapshot = QuotaSnapshot(
account_id="account-1",
provider="antigravity",
buckets=[QuotaBucket(id="b", display_name="Gemini 5h")],
unavailable_reason="Авторизация недоступна",
)
assert snapshot.buckets[0].remaining_percent is None
assert snapshot.unavailable_reason == "Авторизация недоступна"