diff --git a/COCKPIT_TOOLS_ARCHITECTURE_COMPARISON.md b/COCKPIT_TOOLS_ARCHITECTURE_COMPARISON.md new file mode 100644 index 0000000..9de6a3a --- /dev/null +++ b/COCKPIT_TOOLS_ARCHITECTURE_COMPARISON.md @@ -0,0 +1,1137 @@ +# COCKPIT TOOLS ARCHITECTURE COMPARISON +## Hermes Hub vs Cockpit Tools — Сравнительный архитектурный аудит + +> **Дата анализа:** 2026-08-20 +> **Версии:** Hermes Hub (local) · Cockpit Tools (main, v1.3.24) +> **Цель:** Определить оптимальную GUI-архитектуру для Hermes Hub. +> **Методология:** Прямой анализ исходного кода обоих проектов. +> VERIFIED = подтверждено кодом с указанием строк. INFERENCE = логический вывод, явно помечен. + +--- + +## Содержание + +1. [Реальная архитектура Hermes Hub](#1-реальная-архитектура-hermes-hub) +2. [Поток данных Hermes Hub](#2-поток-данных-hermes-hub) +3. [Критические проблемы текущей реализации](#3-критические-проблемы-текущей-реализации) +4. [Реальная архитектура Cockpit Tools](#4-реальная-архитектура-cockpit-tools) +5. [Детальный разбор Cockpit Scheduler](#5-детальный-разбор-cockpit-scheduler) +6. [State Management: сравнение подходов](#6-state-management-сравнение-подходов) +7. [Debounce / Deduplication / Concurrency Protection](#7-debounce--deduplication--concurrency-protection) +8. [GUI Rendering: стоимость обновлений](#8-gui-rendering-стоимость-обновлений) +9. [Tauri + Python IPC: варианты интеграции](#9-tauri--python-ipc-варианты-интеграции) +10. [Будущая страница Маршрутизация: feasibility](#10-будущая-страница-маршрутизация-feasibility) +11. [Backend State vs UI State: событийная модель](#11-backend-state-vs-ui-state-событийная-модель) +12. [Предлагаемая Refresh Architecture](#12-предлагаемая-refresh-architecture) +13. [Сравнительная таблица (25+ критериев)](#13-сравнительная-таблица-25-критериев) +14. [Cockpit patterns worth adopting](#14-cockpit-patterns-worth-adopting) +15. [Cockpit patterns NOT worth copying](#15-cockpit-patterns-not-worth-copying) +16. [Migration Plan: Tauri + React (Plan B)](#16-migration-plan-tauri--react-plan-b) +17. [Alternative Plan: CustomTkinter Optimization (Plan A)](#17-alternative-plan-customtkinter-optimization-plan-a) +18. [FINAL RECOMMENDATION](#18-final-recommendation) + +--- + +## 1. Реальная архитектура Hermes Hub + +### Стек (VERIFIED из исходного кода) + +| Слой | Технология | Файл-источник | +|------|------------|---------------| +| Launcher | C# WinForms, `UseShellExecute=true` | `launcher/HermesHub.cs` | +| GUI Runtime | Python + CustomTkinter | `router/hermes_hub_app.py` | +| Entry Point | `launch_hub()` → `HermesHubApp(ctk.CTk).mainloop()` | `HermesHub.cs:46` | +| Web GUI (альт.) | FastAPI + HTML (`gui_cockpit.html`, 31KB) | `router/gui_server.py` | +| Provider Adapters | Python классы | `router/adapters/*.py` | +| Backend | `agy_subprocess.py` → subprocess `agy` CLI | `agy_subprocess.py:496` | +| Config | YAML (`router_profiles.yaml`) | `config/` | +| Persistence | JSON files + Windows Credential Manager | `router/profile_manager.py` | +| Health | `UnifiedHealthService` (singleton, RLock, cache 30s) | `router/unified_health.py` | +| Event Log | `EventLogService` (singleton, RLock, cap 200) | `unified_health.py:177` | +| Scheduler | `ScheduledTaskSafetyCoordinator` (skip policy) | `router/scheduler/task_safety.py` | +| Session Affinity | `SessionAffinityTracker` (RLock, **NO TTL!**) | `router/session_affinity.py` | +| Health Tracking | `HealthTracker` (RLock, atomic persist, cooldowns) | `router/health_tracker.py` | + +### Lifecycle (VERIFIED) + +``` +launch_hub() + check_single_instance() [Win32 Mutex — handle GC'd immediately! Bug] + HermesHubApp.__init__() + _build_layout() + for key in nav_items: + self._views[key] = self._create_view(key) # pre-instantiate ALL 8 views + _show_view("team") + self.after(50, self._refresh_data) # single refresh at startup + app.mainloop() # MAIN THREAD blocks here +``` + +### LLM Request Path (VERIFIED) + +``` +Hermes Request + hermes_plugin.antigravity_llm_execution() [sync, blocks up to 210s] + engine.route_request(request) + adapter.invoke(profile, request) + AntigravityAdapter -> agy_generate() -> subprocess.run(timeout=210s) + CodexAdapter -> HTTPS + OpenCodeAdapter -> HTTPS + [on Exception] -> fallback: agy_generate() [SILENT fallback! Bug] +``` + +--- + +## 2. Поток данных Hermes Hub + +### Provider/API → GUI (обновление данных) + +``` +Кнопка "🔄 Обновить" (или after(50,...) или after(300,...)) + UI thread: HermesHubApp._refresh_data() + daemon thread: _load() + UnifiedHealthService.scan_all() + load_router_config() [YAML disk read] + ProfileAuthManager.get_profile_status() x N [auth.json x N reads] + engine.health.get_or_create(pid) x N [in-memory] + ИТОГО: ~16 file reads при cache miss (30s TTL) + get_system_readiness() <-- вызывает scan_all() ещё раз! + after(0, _on_data_loaded) + UI thread: _on_data_loaded(readiness) + for v in self._views.values(): # ВСЕ 8 view, включая скрытые! + v.update_data() + +AccountsView.update_data(): # САМЫЙ ДОРОГОЙ ВЫЗОВ + service.scan_all() # ещё раз scan_all! (может быть cached) + for prov_key, scroll in tab_scrolls.items(): # 3 provider tabs + for w in scroll.winfo_children(): + w.destroy() # УНИЧТОЖАЕТ ВСЕ карточки + for p in profiles: + card = _build_account_card(scroll, p) # ~15-20 CTk widgets per card +``` + +### GUI action → backend → UI update + +``` +"⚡ Тест" button -> AccountsView._trigger_action("test", profile_vm) + -> HermesHubApp._handle_action("test", data) + -> _show_toast("⚡ Тестирование...") + -> _run_in_thread(do_test_profile) + [daemon thread] adapter.invoke() [blocks up to 210s] + after(0, on_success) + after(300, self._refresh_data) <- ПОЛНЫЙ scan_all() через 300ms! +``` + +**Ключевая проблема:** каждое действие → полный `scan_all()` через 300ms, +независимо от масштаба изменения. + +--- + +## 3. Критические проблемы текущей реализации + +### [SEVERITY: HIGH] Проблема 1: destroy/recreate в AccountsView + +**FILE:** `router/ui/views/accounts_view.py` +**FUNCTION:** `update_data()`, lines 105–107 (VERIFIED) + +```python +for prov_key, scroll in self.tab_scrolls.items(): + for w in scroll.winfo_children(): + w.destroy() # УНИЧТОЖАЕТ ВСЕ виджеты + for p in profiles: + card = self._build_account_card(scroll, p) # ~15-20 CTk widgets каждый +``` + +При 16 аккаунтах: ~240–320 widget операций per refresh. +При 50 аккаунтах: **~750–1000 widget операций**. Всё в UI thread. Заметные freeze. + +**КОНТРАСТ (VERIFIED):** `TeamView` (`team_view.py:293-305`) делает ПРАВИЛЬНО — widget reuse: + +```python +while len(self._card_widgets) < len(agents): + card = AgentCardWidget(self.cards_grid, ...) + self._card_widgets.append(card) +for idx, agent in enumerate(agents): + self._card_widgets[idx].update_agent(agent) # только .configure()! +``` + +### [SEVERITY: HIGH] Проблема 2: scan_all() при каждом update_data() + +**FILE:** `accounts_view.py:102-103` (VERIFIED) + +```python +def update_data(self, app_state=None): + service = UnifiedHealthService.get() + profiles_by_prov = service.scan_all() # при КАЖДОМ update_data! +``` + +**FILE:** `routing_view.py:42-43` (VERIFIED) — аналогичный паттерн + destroy/recreate. + +### [SEVERITY: HIGH] Проблема 3: полный refresh после любого action + +**FILE:** `hermes_hub_app.py:487` (VERIFIED) + +```python +def _run_in_thread(self, func, on_success=None, on_error=None): + def _worker(): + result = func() + if on_success: self.after(0, lambda: on_success(result)) + self.after(300, self._refresh_data) # ВСЕГДА полный scan_all! +``` + +### [SEVERITY: MEDIUM] Проблема 4: обновление ВСЕХ скрытых view + +**FILE:** `hermes_hub_app.py:367-372` (VERIFIED) + +```python +for v in self._views.values(): + if hasattr(v, "update_data"): + v.update_data() # ВСЕ 8 view, даже pack_forget()'d невидимые +``` + +### [SEVERITY: MEDIUM] Проблема 5: _restore_status() блокирует UI thread + +**FILE:** `hermes_hub_app.py:509-512` (VERIFIED) + +```python +def _restore_status(self): + service = UnifiedHealthService.get() + readiness = service.get_system_readiness() # может блокировать UI thread! + # вызывается через after(6000,...) после КАЖДОГО toast +``` + +`get_system_readiness()` → `scan_all()` → до 16 file reads при cache miss. + +### [SEVERITY: MEDIUM] Проблема 6: нет авто-refresh в GUI + +VERIFIED: В `hermes_hub_app.py` только `after(50,...)` при старте и `after(300,...)` +после action. Нет рекурсивного `after(interval,...)`. +`ScheduledTaskSafetyCoordinator` существует, но **НЕ подключён к GUI refresh**. + +### [SEVERITY: MEDIUM] Проблема 7: singleton race conditions + +FILES: `router_engine.py:254-258`, `unified_health.py:186-189,241-244` + +```python +if cls._instance is None: + cls._instance = cls() # два потока оба видят None -> race! +``` + +### [SEVERITY: MEDIUM] Проблема 8: Win32 mutex handle GC'd + +**FILE:** `hermes_hub_app.py:530-551` (VERIFIED) +`mutex = kernel32.CreateMutexW(...)` — локальная переменная, выходит из scope сразу. +Single-instance guard фактически не работает. + +### [SEVERITY: MEDIUM] Проблема 9: SessionAffinityTracker без TTL + +**FILE:** `session_affinity.py` (VERIFIED) +`_sessions` dict растёт без ограничений. `session_affinity_ttl_seconds=1800` +определён в конфиге, но **никогда не используется** для eviction. Memory leak. + +### [SEVERITY: MEDIUM] Проблема 10: disk I/O внутри RLock + +**FILES:** `health_tracker.py`, `unified_health.py` +`mark_success()`, `log()`, `scan_all()` держат RLock во время file I/O. +Медленный диск → stall всех потоков ожидающих lock. + +--- + +## 4. Реальная архитектура Cockpit Tools + +### Стек (VERIFIED из исходников) + +| Слой | Технология | Источник-доказательство | +|------|------------|------------------------| +| Frontend | React + TypeScript + Vite | `package.json` | +| State | Zustand + `persist` middleware | `useAccountStore.ts:9-10` | +| Desktop Shell | Tauri v2 (Rust, tokio async) | `src-tauri/Cargo.toml` | +| Backend | Rust crates (cockpit-core) | `crates/cockpit-core/` | +| Sidecar | Go `cockpit-cliproxy` | `sidecars/cockpit-cliproxy/` | +| IPC | `invoke()` — Tauri Commands | `accountService.ts:15` | +| Events | `listen()` — Tauri Events | `accountSyncEvents.ts` | +| Persistence | localStorage (Zustand) + SQLite (Rust) | `useAccountStore.ts`, `db.rs` | +| Quota Cache | Rust `quota_cache.rs` | `crates/cockpit-core/src/modules/` | +| macOS Interop | Swift via `swift-rs` crate | `src-tauri/Cargo.toml` | + +### Rust Backend (VERIFIED из Cargo.toml и file tree) + +``` +crates/cockpit-core/src/modules/ + account.rs — account management (66KB) + quota.rs — quota fetching (46KB) + quota_cache.rs — quota cache (5KB) + db.rs — SQLite/rusqlite (7KB) + process.rs — process/sidecar management (325KB!) + websocket.rs — WebSocket server (35KB) + config.rs — config management (73KB) + *_account.rs — 14 провайдеров, по модулю на каждый +``` + +**ВАЖНО:** Cockpit — Rust-первая архитектура. ~99% бизнес-логики в Rust. +React — тонкая отображающая оболочка. + +### Tauri Commands (VERIFIED из accountService.ts и codexService.ts) + +```typescript +invoke('list_accounts') +invoke('add_account', { refreshToken }) +invoke('delete_account', { accountId }) +invoke('reorder_accounts', { accountIds }) +invoke('get_current_account', { runtimeTarget }) +// Codex-specific: +invoke('switch_codex_account', { accountId, autoRepairMode: null }) +invoke('refresh_codex_account_profile', { accountId }) +invoke('save_codex_quick_config', { modelContextWindow, autoCompactTokenLimit }) +``` + +### Cockpit Event Bus (VERIFIED из accountSyncEvents.ts) + +```typescript +export const ACCOUNTS_CHANGED_EVENT = 'accounts:changed'; +export const CURRENT_ACCOUNT_CHANGED_EVENT = 'accounts:current-changed'; +export const ACTIVE_PLATFORM_FOCUS_EVENT = 'platform:active-focus'; +// emitAccountsChanged() -> Tauri emit() -> все Tauri windows +``` + +--- + +## 5. Детальный разбор Cockpit Scheduler + +### autoRefreshScheduler.ts (VERIFIED — полный код получен) + +```typescript +export interface AutoRefreshSchedulerTask { + key: string; // уникальный ключ ("antigravity:full") + label: string; + intervalMs: number; // интервал обновления + run: () => Promise; + shouldSkip?: () => boolean; // предикат пропуска + initialDelayMs?: number; // задержка старта (auto если не задана) +} + +interface RuntimeTask extends AutoRefreshSchedulerTask { + nextRunAt: number; // timestamp следующего запуска + running: boolean; // guard: задача выполняется прямо сейчас +} + +const DEFAULT_TICK_MS = 5_000; // проверка очереди каждые 5s +const DEFAULT_MAX_CONCURRENT = 1; // max 1 задача одновременно +const INITIAL_DELAY_WINDOW_RATIO = 0.8; +const MIN_INITIAL_DELAY_RATIO = 0.05; +``` + +### stableHash (VERIFIED — verbatim) + +```typescript +function stableHash(value: string): number { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) >>> 0; + } + return hash >>> 0; // DJB2-подобный, всегда uint32 +} +``` + +**Назначение:** детерминированный spread `initialDelayMs` на основе `task.key`. +`"antigravity:full"` и `"codex:full"` стартуют в разное время — нет thundering herd. + +### Tick Loop (INFERENCE из типов и констант — стандартный паттерн) + +```typescript +// createAutoRefreshScheduler() создаёт: +setInterval(() => { + const runningCount = tasks.filter(t => t.running).length; + if (runningCount >= maxConcurrent) return; // concurrency gate + + const now = Date.now(); + for (const task of tasks) { + if (task.running) continue; // running guard + if (task.nextRunAt > now) continue; // ещё не время + if (task.shouldSkip?.()) continue; // predicate check + + task.running = true; + task.run().finally(() => { + task.running = false; + task.nextRunAt = now + clampIntervalMs(task.intervalMs); + }); + break; // только ОДНА задача за тик (MAX_CONCURRENT=1) + } +}, tickMs); // tickMs = 5000ms +``` + +Из 180 тиков в час для 15-минутного refresh — 179 ничего не делают. + +### Full Refresh vs Current-Account Refresh (VERIFIED из useAutoRefresh.ts) + +```typescript +// useAutoRefresh.ts:113-122 +interface PlatformRefreshDescriptor { + key: CurrentAccountRefreshPlatform; // 'antigravity', 'codex', etc. + intervalMinutes: number; // full refresh ВСЕХ аккаунтов провайдера + currentMinutes: number; // refresh ТОЛЬКО активного аккаунта + runFullRefresh: () => Promise; // invoke('list_*_accounts') + runCurrentRefresh: () => Promise; // invoke('get_current_*_account') +} +``` + +Две отдельные задачи на провайдер в scheduler с разными `intervalMs`. + +### Constants (VERIFIED) + +```typescript +const STARTUP_AUTO_REFRESH_SETUP_DELAY_MS = 2500; // 2.5s задержка при старте +const AUTO_REFRESH_TICK_MS = 5_000; +const AUTO_REFRESH_MAX_CONCURRENT = 1; +``` + +--- + +## 6. State Management: сравнение подходов + +### Cockpit: Zustand + atomic per-account updates + +`useCodexAccountStore.ts` — verbatim (VERIFIED): + +```typescript +const mergeCodexAccountIntoList = ( + accounts: CodexAccount[], + account: CodexAccount, +): CodexAccount[] => { + const index = accounts.findIndex((item) => item.id === account.id); + if (index < 0) { + return [account, ...accounts]; // prepend if new + } + const next = [...accounts]; + next[index] = account; // splice by index, НЕ full rebuild! + return next; +}; +``` + +`useAccountStore.ts` — debounce state (VERIFIED verbatim): + +```typescript +let fetchAccountsPromise: Promise | null = null; +let fetchAccountsSeq = 0; +const DEBOUNCE_MS = 500; +``` + +**React re-render scope при обновлении Account 27 из 50:** +- `AccountCard_1..26` → NO re-render (reference unchanged) +- `AccountCard_27` → RE-RENDER +- `AccountCard_28..50` → NO re-render + +### Hermes Hub: singleton + destroy/recreate + +```python +# unified_health.py:246-257 (VERIFIED) +def scan_all(self, force=False): + with self._lock: + if not force and self._cached_profiles and \ + (time.time() - self._last_scan_time < 30): + return result # cache hit — OK + # cache miss: полное пересканирование + config = load_router_config() # disk I/O + for pid, pcfg in config.profiles.items(): + auth_status = ProfileAuthManager.get_profile_status(prov, pid) # file read x N +``` + +| Аспект | Hermes Hub | Cockpit | +|--------|-----------|---------| +| Обновление одного аккаунта | Rebuild ВСЕ карточки | splice-by-index + 1 re-render | +| Подписка на изменения | Нет | Zustand selector per component | +| Backend cache | 30s TTL (scan_all) | Rust `quota_cache.rs` | +| Persistent state | Disk JSON files | localStorage + SQLite | + +--- + +## 7. Debounce / Deduplication / Concurrency Protection + +### Cockpit: Promise Reuse + Sequence Numbers (VERIFIED verbatim) + +```typescript +// useAccountStore.ts +let fetchAccountsPromise: Promise | null = null; +let fetchAccountsSeq = 0; + +async function fetchAccounts() { + if (fetchAccountsPromise) { + return fetchAccountsPromise; // reuse: только один реальный запрос + } + fetchAccountsSeq += 1; + const seq = fetchAccountsSeq; + + fetchAccountsPromise = (async () => { + const accounts = await invoke('list_accounts'); + if (seq === fetchAccountsSeq) { // stale protection + set({ accounts }); + } + })().finally(() => { fetchAccountsPromise = null; }); + + return fetchAccountsPromise; +} +``` + +### Hermes Hub: существующие защиты (VERIFIED) + +```python +# task_safety.py:59-61 +if spec.is_running: + if spec.overlap_policy == "skip": + return False # overlap guard — правильный паттерн +``` + +НО: `ScheduledTaskSafetyCoordinator` **НЕ интегрирован** в GUI refresh loop! + +### Сравнительная таблица + +| Паттерн | Cockpit | Hermes Hub | +|---------|---------|-----------| +| Promise/Future reuse | ✅ `fetchAccountsPromise` | ❌ нет | +| Debounce 500ms | ✅ `DEBOUNCE_MS=500` | ❌ нет | +| Stale request seq# | ✅ `fetchAccountsSeq` | ❌ нет | +| Running guard | ✅ `task.running` | ✅ `spec.is_running` | +| Concurrency limit | ✅ `MAX_CONCURRENT=1` | ✅ `overlap_policy="skip"` | +| Per-provider intervals | ✅ per-platform minutes | ❌ нет авто-refresh в GUI | +| stableHash initial delay | ✅ | ❌ нет | +| Backend quota cache | ✅ `quota_cache.rs` | ❌ нет | + +--- + +## 8. GUI Rendering: стоимость обновлений + +### Сценарий: 50 аккаунтов, обновилась quota Account 27 + +**Cockpit (Tauri + React + Zustand):** +``` +Rust: quota updated for "acc-27" + → Tauri emit("account_updated", {id: "acc-27", quota: {...}}) + → React listener + → store.mergeAccountIntoList("acc-27", newQuota) // splice by index + → React reconciliation: + AccountCard_1..26 → NO re-render + AccountCard_27 → RE-RENDER + AccountCard_28..50 → NO re-render + +COST: O(1) splice + 1 React component re-render +``` + +**Hermes Hub (текущий):** +``` +Кнопка "🔄 Обновить" + → daemon thread: UnifiedHealthService.scan_all() [~16 file reads] + → _on_data_loaded() + → AccountsView.update_data(): + scan_all() [ещё раз] + for prov in 3 tabs: + destroy all children (~320 widget destroys при 16 accounts) + rebuild all cards (~320 widget creates) + +COST: 16+ file reads + ~640 CTk widget операций +При 50 аккаунтах: ~1320+ widget операций +``` + +**Hermes Hub (после STEP 1 оптимизации — widget reuse):** +``` +AccountsView.update_data(): + for idx, p in enumerate(profiles): + self._card_widgets[idx].update_profile(p) # только .configure() + +COST: N configure() calls — O(N), нет destroys +``` + +--- + +## 9. Tauri + Python IPC: варианты интеграции + +### КЛЮЧЕВОЙ ФАКТ: `gui_server.py` уже существует! + +Hermes Hub содержит полноценный FastAPI backend (`router/gui_server.py`) с REST API: + +```python +@app.get("/api/team") → AutoAssigner.build_team_hierarchy() +@app.get("/api/status") → полный статус всех профилей +@app.post("/api/set-main") → ProfileAuthManager +@app.post("/api/test-profile") → adapter.invoke() +@app.post("/api/start-oauth") → start_profile_oauth() +@app.post("/api/auto-assign") → AutoAssigner +``` + +Это кардинально снижает стоимость Tauri migration. + +### Вариант A: Tauri Frontend → Python FastAPI (localhost HTTP) + +``` +React/TypeScript (Tauri WebView2) + fetch('http://127.0.0.1:PORT/api/...') +FastAPI (gui_server.py — УЖЕ СУЩЕСТВУЕТ) + Python function calls +Hermes Hub Python backend +``` + +| Критерий | Оценка | +|----------|--------| +| Сложность старта | **НИЗКАЯ** — FastAPI уже есть | +| IPC latency | ~1-5ms (localhost HTTP) | +| Security | ⚠️ открытый localhost порт | +| Packaging | Средняя — два процесса | +| **ИТОГ** | **Наименьшее сопротивление. Стартовая точка.** | + +### Вариант B: Tauri Sidecar → Python stdin/stdout JSON-RPC + +``` +Tauri (Rust) + Sidecar API (spawn + pipe, как cockpit-cliproxy в Go) +Python process (JSON-RPC over stdio) +``` + +| Критерий | Оценка | +|----------|--------| +| Security | ✅ нет сетевых портов | +| Latency | ~0.1ms (stdio) | +| Packaging | Хорошая — Tauri bundled sidecar | +| **ИТОГ** | **Лучший для production, сложнее старт** | + +REFERENCE: Cockpit использует этот паттерн с Go `cockpit-cliproxy`. + +### Вариант C: WebSocket (push updates) + +``` +React (WebSocket client) + ws://localhost:PORT +Python asyncio WebSocket server +``` + +**ИТОГ:** Идеально дополняет Вариант A для real-time quota/events без polling. + +### Что НЕ нужно + +Перепись backend на Rust — **не обоснована**. +Python backend содержит сложную логику (OAuth, subprocess, YAML routing). +Перепись: 6-12 месяцев без UX-выгоды для пользователя. + +**РЕКОМЕНДОВАННЫЙ порядок IPC:** +1. Старт: Вариант A (FastAPI уже есть) +2. Добавить: Вариант C (WebSocket для push events) +3. Optionally: Вариант B (sidecar для лучшей packaging) + +--- + +## 10. Будущая страница Маршрутизация: feasibility + +**Требования:** drag-and-drop агентов в failover цепочках, выбор provider/account/model, live quota в карточках, включение/отключение, priority management. + +**Текущий статус (VERIFIED):** `routing_view.py` — read-only. Нет интерактивности. + +### CustomTkinter + +- **Drag-and-drop:** нет нативной поддержки. `bind("")` + ручное перемещение — ~500-1000 строк хрупкого кода. **HARD.** +- **Live quota:** приемлемо после widget reuse оптимизации. +- **Dropdowns:** `CTkComboBox` работает, ограниченный стиль. +- **ИТОГ: HARD/POSSIBLE** — высокие усилия, хрупкое решение. + +### React/Tauri + +- **Drag-and-drop:** `@dnd-kit/core` — production-ready, 20-30 строк. **EASY.** +- **Live quota:** Zustand + Tauri event → atomic update одной карточки. +- **Force graph / network viz:** `react-flow` или `D3.js` — готовые решения. +- **ИТОГ: EASY** — стандартная React задача. + +--- + +## 11. Backend State vs UI State: событийная модель + +### Предлагаемая модель для Hermes Hub + +``` +[Provider Adapters] + agy_subprocess, Codex HTTPS, OpenCode HTTPS + ↓ результаты запросов +[Health/Account Services] + UnifiedHealthService, HealthTracker + ↓ публикует delta-события: + +EventBus: + ACCOUNT_UPDATED { profile_id, auth_state, quota_data } + QUOTA_UPDATED { profile_id, model_family, status, reset_at } + PROVIDER_HEALTH_CHANGED { provider, status } + ROUTE_CHANGED { role_id, profile_id, reason } + + ↓ +[GUI — только получает события] + AccountCardWidget._on_account_updated(data) ← только нужная карточка + MetricCard._on_readiness_changed(data) ← без полного rebuild +``` + +### Python EventBus (~50 строк, работает с любым GUI) + +```python +from collections import defaultdict +import threading + +class EventBus: + _listeners = defaultdict(list) + _lock = threading.Lock() + + @classmethod + def subscribe(cls, event_type: str, callback): + with cls._lock: + cls._listeners[event_type].append(callback) + + @classmethod + def emit_in_ui(cls, root, event_type: str, data: dict): + """Thread-safe emit to UI thread via tkinter.after()""" + with cls._lock: + handlers = list(cls._listeners[event_type]) + for h in handlers: + root.after(0, lambda h=h, d=data: h(d)) + +# Использование в AccountsView: +EventBus.subscribe("ACCOUNT_UPDATED", self._on_account_updated) + +def _on_account_updated(self, data: dict): + profile_id = data["profile_id"] + for widgets_list in self._card_widgets.values(): + for card in widgets_list: + if card._profile_id == profile_id: + card.update_profile_vm(data["profile_vm"]) + return +``` + +--- + +## 12. Предлагаемая Refresh Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ HermesRefreshScheduler (5s tick) │ +│ ┌──────────────┬─────────────┬──────────┬─────────────┐ │ +│ │ antigrav:full│ codex:full │ opencode │ orch:current│ │ +│ │ 15 min │ 15 min │ 30 min │ 1 min │ │ +│ │ stableHash │ stableHash│ ... │ skip guard │ │ +│ └──────┬───────┴──────┬──────┴────┬─────┴──────┬──────┘ │ +│ │ │ │ │ │ +└─────────┼──────────────┼───────────┼────────────┼──────────┘ + ↓ ↓ ↓ ↓ + AG adapter Codex HTTPS OpenCode orch only + │ │ │ │ + └──────────────┴───────────┴────────────┘ + ↓ + delta: { profile_id, field, value } + ↓ + EventBus.emit_in_ui("ACCOUNT_UPDATED", delta) + ↓ + widget._on_account_updated(delta) ← O(1) +``` + +**Отличия от текущего:** +1. Scheduler в отдельном сервисе, не в GUI +2. GUI подписывается на события, не вызывает `scan_all()` +3. Full и orchestrator-refresh — разные задачи с разными интервалами +4. `MAX_CONCURRENT = 1` — нет API storm +5. `stableHash` initial delay — нет thundering herd при старте + +--- + +## 13. Сравнительная таблица (25+ критериев) + +| Критерий | Сейчас | Plan A (CTk+Opt) | Plan B (Tauri+React) | +|----------|--------|-----------------|---------------------| +| UI responsiveness | ⚠️ freeze при rebuild | ✅ хорошая при delta | ✅✅ 60fps | +| Partial updates | ❌ только TeamView | ✅ после рефакторинга | ✅✅ native React | +| 50+ account lists | ❌ 1000+ widget ops | ✅ widget reuse | ✅✅ + virtualization | +| Drag-and-drop | ❌ нет | ⚠️ ~1000 строк кода | ✅✅ @dnd-kit 20 строк | +| State management | ⚠️ singleton+RLock | ✅ EventBus+delta | ✅✅ Zustand atomic | +| Auto-refresh GUI | ❌ нет | ✅ scheduler | ✅✅ hook integration | +| Animations | ❌ нет | ❌ минимальные CTk | ✅✅ CSS/Framer Motion | +| Routing editor | ❌ read-only | ⚠️ D&D сложно | ✅✅ react-flow | +| Agent network viz | ❌ нет | ❌ нет в CTk | ✅✅ D3.js/react-flow | +| Virtualized lists | ❌ нет | ❌ нет в CTk | ✅✅ react-virtual | +| Modals/dialogs | ✅ CTkToplevel | ✅ | ✅✅ React portals | +| Dark mode | ✅ CTk built-in | ✅ | ✅✅ CSS variables | +| System tray | ✅ pystray/win32 | ✅ | ✅✅ Tauri tray API | +| Notifications | ✅ toast | ✅ | ✅✅ Tauri notifications | +| Windows packaging | ✅ .exe C# launcher | ✅ PyInstaller | ✅✅ MSI/NSIS | +| macOS packaging | ⚠️ | ⚠️ | ✅ dmg/pkg | +| Python integration | ✅✅ native | ✅✅ native | ✅ via FastAPI | +| Dev complexity | ✅ 1 язык | ✅ 1 язык | ⚠️ Rust+TS+Python | +| Migration cost | ✅ нет | ✅ рефакторинг | ⚠️ 6-9 месяцев | +| Maintenance | ✅ Python-only | ✅ Python-only | ⚠️ 3 технологии | +| Testing | ✅ pytest | ✅ pytest | ✅ pytest+vitest+cargo | +| Startup time | ⚠️ 3-5s Python | ⚠️ 3-5s | ✅ 1-2s Tauri | +| Memory footprint | ⚠️ ~80-150MB | ⚠️ ~80-150MB | ⚠️ ~40MB+Python | +| Security surface | ✅ нет web | ✅ нет | ⚠️ WebView surface | +| Future web version | ❌ нет | ❌ нет | ✅ React reuse | +| Mobile support | ❌ | ❌ | ✅ Tauri 2 mobile | + +--- + +## 14. Cockpit patterns worth adopting + +### PATTERN 1: Centralized Scheduler с stableHash + +- **Как:** `createAutoRefreshScheduler()` с `RuntimeTask.nextRunAt`, tick 5s, `stableHash(key)` initial delays +- **Почему:** Tick = O(1) проверка. API-запрос только при `nextRunAt <= now`. Нет thundering herd. +- **Для Hermes Hub:** Высокая применимость. Python реализация ~80 строк. Работает с любым GUI. +- **Сложность:** Низкая + +### PATTERN 2: Per-provider refresh intervals + +- **Как:** `PlatformRefreshDescriptor.intervalMinutes` per provider +- **Почему:** Разные провайдеры имеют разные rate limits +- **Для Hermes Hub:** Добавить `refresh_interval_minutes` в `router_profiles.yaml` +- **Сложность:** Низкая + +### PATTERN 3: Full refresh vs Orchestrator-only refresh + +- **Как:** Две задачи на провайдер — `runFullRefresh` (редко) и `runCurrentRefresh` (часто) +- **Для Hermes Hub:** Refresh orchestrator чаще остальных — паттерн напрямую применим +- **Сложность:** Средняя + +### PATTERN 4: Promise/Future deduplication + +- **Как:** `fetchAccountsPromise: Promise | null` — повторный вызов = тот же Promise +- **Для Hermes Hub:** `_pending_refresh: Optional[threading.Thread] = None` — ~10 строк +- **Сложность:** Низкая + +### PATTERN 5: Sequence number stale protection + +- **Как:** `fetchAccountsSeq` инкрементируется; старые результаты игнорируются +- **Для Hermes Hub:** Python threading counter — ~5 строк +- **Сложность:** Низкая + +### PATTERN 6: Widget reuse вместо destroy/recreate + +- **Как:** React reconciliation — только изменённые компоненты рендерятся +- **Для Hermes Hub:** `TeamView` уже делает это. Применить к `AccountsView` и `RoutingView`. +- **Сложность:** Средняя — рефакторинг `AccountCardWidget` по образцу `AgentCardWidget` + +### PATTERN 7: Typed EventBus + +- **Как:** Tauri `emit(EVENT_NAME)` / `listen()` +- **Для Hermes Hub:** Расширить `EventLogService` до pub/sub — ~50 строк Python +- **Сложность:** Низкая + +### PATTERN 8: Startup delay spreading + +- **Как:** `STARTUP_AUTO_REFRESH_SETUP_DELAY_MS = 2500` +- **Для Hermes Hub:** `self.after(50, ...)` → `self.after(2500, ...)` — **одна цифра!** +- **Сложность:** Тривиальная + +--- + +## 15. Cockpit patterns NOT worth copying + +### НЕ КОПИРОВАТЬ: Per-provider Zustand stores (15 отдельных stores) + +Cockpit — account manager для однородных провайдеров. +Hermes Hub — Multi-Agent Control Hub с отношениями `profile→role→routing_chain→session_affinity`. +Separate stores создадут silos, где routing decisions сложно агрегировать. +**Лучше для Hermes Hub:** единый `AgentStateStore` с `profiles: Dict[str, ProfileViewModel]`. + +### НЕ КОПИРОВАТЬ: Account-switcher архитектура + +Cockpit: один активный аккаунт, остальные резерв. +Hermes Hub работает с **несколькими аккаунтами одновременно** (orchestrator + coder1 + coder2 + reviewer). Принципиально другая модель. + +### НЕ КОПИРОВАТЬ: Instance management (process.rs, 325KB) + +Cockpit: spawn IDE instances per account. +Hermes Hub — control hub для routing, не IDE launcher. + +### НЕ КОПИРОВАТЬ: localStorage для credentials + +Cockpit: AccountStore → localStorage (Zustand persist), credentials scrubbed. +Hermes Hub хранит credentials в OS-level storage (Windows CM + files). Это правильно для Desktop. + +### НЕ КОПИРОВАТЬ: Backend перепись на Rust + +Cockpit: 99% бизнес-логики в Rust. +Python backend Hermes Hub содержит сложную логику (OAuth, subprocess, YAML routing). +Перепись: 6-12 месяцев без UX-выгоды. +**Оставить Python backend, заменить только GUI.** + +--- + +## 16. Migration Plan: Tauri + React (Plan B) + +> CTk GUI продолжает работать на ВСЕХ фазах до Phase 7. + +### Phase 0 — API Contract (2 недели) + +Расширить `gui_server.py` до полного coverage всех операций `_handle_action()`. +Добавить WebSocket endpoint для push-событий. Написать OpenAPI spec. +**ROLLBACK:** Нет изменений в production code. + +### Phase 1 — Tauri Shell (3 недели) + +Minimal Tauri app + Python FastAPI sidecar, проверить round-trip. +Files: `tauri-app/` (Vite+React+TypeScript), `tauri-app/src-tauri/` (Rust shell), `hermesApi.ts`. +**ROLLBACK:** Удалить `tauri-app/`; CTk продолжает работать. + +### Phase 2 — Navigation Shell (2 недели) + +React shell с sidebar, без бизнес-данных. `Sidebar.tsx`, `Layout.tsx`, `useUIStore.ts`. +Risk: DPI scaling, Windows font rendering в WebView2. + +### Phase 3 — Accounts View (4 недели) + +React AccountsView с реальными данными и delta updates. +`useProfileStore.ts`, `AccountCard.tsx`, WebSocket listener для `ACCOUNT_UPDATED`. + +### Phase 4 — Team + Routing View (4 недели) + +`TeamView.tsx`, `AgentCard.tsx`, read-only `RoutingView.tsx`. + +### Phase 5 — Routing Editor с D&D (6 недель) + +Интерактивный routing editor. `@dnd-kit/core` dep, `DraggableAgentSlot.tsx`. +**Risk:** Самая сложная phase. D&D state + Zustand. +**ROLLBACK:** CTk RoutingView остаётся для power-users. + +### Phase 6 — Settings, Logs, Health, About (2 недели) + +Оставшиеся views. + +### Phase 7 — Tray + Notifications (2 недели) + +`src-tauri/src/tray.rs`, Tauri tray API. + +### Phase 8 — Remove Legacy CTk GUI (1 неделя) + +Criteria: 2+ недели стабильной работы без критических багов. +`gui_server.py` остаётся — он работает независимо. + +--- + +## 17. Alternative Plan: CustomTkinter Optimization (Plan A) + +### STEP 1: Рефакторинг AccountsView — устранить destroy/recreate (ПРИОРИТЕТ 1, ~2 дня) + +```python +class AccountCardWidget(HubCard): + """Delta-update account card — no destroy/recreate.""" + + def __init__(self, master, on_action=None, **kwargs): + super().__init__(...) + # Создать ВСЕ sub-widgets ОДИН РАЗ + self.identity_lbl = ctk.CTkLabel(self, ...) + self.status_dot = ctk.CTkLabel(self, text="●", ...) + self._profile_id: Optional[str] = None + + def update_profile(self, p: ProfileViewModel): + """Delta update — только .configure(), никаких destroy.""" + self._profile_id = p.profile_id + self.identity_lbl.configure(text=p.account_identity) + dot_col = HEALTHY_COLOR if p.health_state == STATUS_HEALTHY else WARN_COLOR + self.status_dot.configure(text_color=dot_col) + # ... другие configure() вызовы + +class AccountsView(ctk.CTkFrame): + def __init__(self, ...): + self._card_widgets: Dict[str, List[AccountCardWidget]] = { + "antigravity": [], "openai-codex": [], "opencode-go": [] + } + + def update_data(self, profiles_by_prov=None): + for prov_key, scroll in self.tab_scrolls.items(): + profiles = profiles_by_prov.get(prov_key, []) if profiles_by_prov else [] + widgets = self._card_widgets[prov_key] + # Создать недостающие widgets + while len(widgets) < len(profiles): + idx = len(widgets) + card = AccountCardWidget(scroll, on_action=self.on_action) + row, col = divmod(idx, 3) + card.grid(row=row, column=col, padx=6, pady=6, sticky="nsew") + widgets.append(card) + # Delta update существующих + for idx, p in enumerate(profiles): + widgets[idx].update_profile(p) + widgets[idx].grid() + # Скрыть лишние + for idx in range(len(profiles), len(widgets)): + widgets[idx].grid_remove() +``` + +**IMPACT:** Устраняет 750-1000 widget ops при 50 аккаунтах. + +### STEP 2: Устранить дублированные scan_all() (ПРИОРИТЕТ 1, ~1 день) + +```python +def _load(): + service = UnifiedHealthService.get() + profiles_by_prov = service.scan_all() # один раз! + readiness = service.get_system_readiness() + pipelines = service.get_routing_pipelines() + agents = service.get_agent_view_models() + self.after(0, lambda: self._on_data_loaded(readiness, profiles_by_prov, pipelines, agents)) + +def _on_data_loaded(self, readiness, profiles_by_prov, pipelines, agents): + # Передаём готовые данные — никаких повторных scan_all! + self._views["accounts"].update_data(profiles_by_prov=profiles_by_prov) + self._views["team"].update_data(readiness=readiness, agents=agents) + self._views["routing"].update_data(pipelines=pipelines) +``` + +### STEP 3: Обновлять только активную вкладку (ПРИОРИТЕТ 2, ~0.5 дня) + +```python +def _on_data_loaded(self, readiness, ...): + current = self._views.get(self._current_view) + if current and hasattr(current, "update_data"): + current.update_data(...) + self._data_stale = False + +def _show_view(self, view_name): + ... + target = self._views.get(view_name) + if target and hasattr(target, "update_data") and self._data_stale: + target.update_data(...) # lazy update при переключении +``` + +### STEP 4: Интегрировать Scheduler в GUI (ПРИОРИТЕТ 2, ~1 день) + +```python +# В HermesHubApp.__init__(): +self._scheduler = ScheduledTaskSafetyCoordinator.get() +self._scheduler.register_task(ScheduledTaskSpec( + task_id="gui_full_refresh", + name="GUI auto-refresh", + cron_or_interval_sec=300.0, # 5 минут + handler=self._background_refresh, + overlap_policy="skip", +)) +self._start_scheduler_ticker() + +def _background_refresh(self): + service = UnifiedHealthService.get() + profiles_by_prov = service.scan_all(force=True) + readiness = service.get_system_readiness() + self.after(0, lambda: self._on_data_loaded(readiness, profiles_by_prov, ...)) +``` + +### STEP 5: Request Deduplication (ПРИОРИТЕТ 2, ~0.5 дня) + +```python +_pending_refresh: Optional[threading.Thread] = None +_refresh_dedup_lock = threading.Lock() + +def _refresh_data(self): + with self._refresh_dedup_lock: + if self._pending_refresh and self._pending_refresh.is_alive(): + return # уже выполняется + t = threading.Thread(target=self._load, daemon=True) + self._pending_refresh = t + t.start() +``` + +### STEP 6: Исправить _restore_status() (ПРИОРИТЕТ 1, ~1 час) + +```python +def _restore_status(self): + # БЫЛО: синхронный вызов get_system_readiness() в UI thread + # СТАЛО: dispatch в background + def _fetch(): + readiness = UnifiedHealthService.get().get_system_readiness() + self.after(0, lambda: self._update_status_bar(readiness)) + threading.Thread(target=_fetch, daemon=True).start() +``` + +### STEP 7: EventBus + delta updates (ПРИОРИТЕТ 3, ~2 дня) + +```python +EventBus.subscribe("ACCOUNT_UPDATED", self._on_account_updated) + +def _on_account_updated(self, data: dict): + profile_id = data.get("profile_id") + for widgets_list in self._card_widgets.values(): + for card in widgets_list: + if card._profile_id == profile_id: + card.update_profile_vm(data["profile_vm"]) + return +``` + +--- + +## 18. FINAL RECOMMENDATION + +### ВЫБОР: **B — Tauri + React frontend, сохранив Python backend** + +### Confidence: **MEDIUM** + +--- + +### Технические основания (VERIFIED из кода) + +**1. Drag-and-drop routing editor — принципиальный ceiling для CTk** + +`routing_view.py` — read-only (VERIFIED). D&D в CTk: ~500-1000 строк хрупкого кода. +D&D в React (`@dnd-kit`): 20-30 строк production-ready. +Если routing editor — ключевая фича, выбор очевиден. + +**2. FastAPI уже есть — IPC стоимость минимальна** + +`gui_server.py` — полноценный FastAPI backend (VERIFIED). +Tauri frontend использует его через `fetch()` без переписывания бизнес-логики. +Это нестандартная ситуация: обычно IPC — самая дорогая часть Tauri migration. + +**3. AccountsView destroy/recreate — structural constraint CTk** + +750-1000 widget ops при 50 аккаунтах per refresh. +Исправляемо через widget reuse, НО виртуализации списков в CTk нет. +При 200+ аккаунтах проблема вернётся на уровне фреймворка. + +**4. React ecosystem для Multi-Agent UI значительно богаче** + +Force graph (react-flow/D3), quota charts (recharts), routing canvas. +CTk эквивалентов нет. + +**5. `gui_cockpit.html` (31KB) + `gui_server.py` доказывают:** + +Web-based GUI уже была идеей в проекте. +Tauri — эволюция этого направления с native desktop интеграцией. + +**6. Python backend остаётся — нет риска потери бизнес-логики** + +`agy_subprocess.py`, `runtime.py`, OAuth flows, adapters — всё остаётся. +Только GUI меняется. Python-only разработка backend продолжается. + +**7. Cockpit доказал: стек production-ready для этого класса задач** + +15+ провайдеров, сотни аккаунтов, scheduler, delta updates. + +--- + +### Почему confidence MEDIUM, а не HIGH + +- Migration 6-9 месяцев при параллельной разработке — высокий cost +- Три технологии (Rust/TypeScript/Python) — выше bus factor +- Если команда 1-2 человека — **Plan A даёт 80% выгоды за 20% усилий** +- CTk Plan A, реализованный правильно, покрывает все требования на 12-18 месяцев + +--- + +### ЕСЛИ РЕСУРСЫ ОГРАНИЧЕНЫ: начать с Plan A, подготовить к Plan B + +Первые шаги одинаковы для обоих путей: + +| Шаг | Трудоёмкость | IMPACT | +|-----|-------------|--------| +| Рефакторинг AccountsView (widget reuse) | 2 дня | Устраняет 1000 widget ops при 50 аккаунтах | +| Устранить дублированные scan_all() | 1 день | Минимум disk I/O per refresh | +| Исправить `_restore_status()` | 1 час | Устраняет UI freeze после каждого toast | +| Startup delay 50ms → 2500ms | 5 минут | Лучший UX при старте | +| Request deduplication | 0.5 дня | Нет дублирующих API calls | +| Подключить Scheduler к GUI | 1 день | Данные обновляются автоматически | +| Расширить REST API spec (`gui_server.py`) | 2 дня | Фундамент для Tauri migration | +| Исправить Win32 mutex handle | 5 минут | Single-instance guard работает | +| Singleton races (добавить Lock) | 15 строк | Thread-safe singletons | +| SessionAffinityTracker TTL eviction | 20 строк | Нет memory leak | + +После стабилизации Plan A → Scaffold Tauri app, проверить round-trip +FastAPI → React, начать Phase 0. + +--- + +*Отчёт составлен на основе прямого анализа исходного кода обоих проектов.* +*VERIFIED = подтверждено кодом с указанием файлов и строк.* +*INFERENCE = логический вывод из видимых паттернов — явно помечен во всех местах.* diff --git a/agents/inbox/2026-08-23-A9-antigravity-quotas-models-migration.md b/agents/inbox/2026-08-23-A9-antigravity-quotas-models-migration.md new file mode 100644 index 0000000..da36956 --- /dev/null +++ b/agents/inbox/2026-08-23-A9-antigravity-quotas-models-migration.md @@ -0,0 +1,223 @@ +# Задание A9 (Antigravity): миграция конфигурации, квоты остальных провайдеров, реальные модели + +## Дата поступления +2026-08-23 + +## База +Проверочный HEAD на момент выдачи: **`b625b0f`**. Обязательно обновить локальную копию — см. следующий раздел. + +## Ветка +`antigravity/quotas-models-migration` + +--- + +## Перед началом: обновить локальную копию + +Вы работаете на другой машине и пушите прямо в git. `main` ушёл далеко вперёд вашей базы: в него влиты и A8, и работа Codex (граф маршрутизации, живые квоты, дефекты живого прогона), и правки ревьюера. + +``` +cd <каталог репозитория>; git fetch origin --prune; git status +``` + +Если рабочее дерево чистое: + +``` +git checkout main; git reset --hard origin/main +``` + +Зафиксировать фактический `BASE_SHA` через `git rev-parse --short HEAD` и указать его в отчёте. Не считать `b625b0f` актуальным автоматически. + +--- + +## Что принято по A8 + +Проверено исполнением: + +- **Самолечение запуска работает.** `launcher_bootstrap` импортируется, `check_missing_dependencies()` на чистой машине возвращает `[]`, все пять функций на месте. +- **`find_free_slot` больше не выдумывает идентификаторы.** Проверено по всем пяти провайдерам мастера: возвращается либо существующий профиль, либо `None`. Это было главным дефектом A8 и он закрыт. +- **Экран переустановки в мастере есть** и подключён к `SetupEngine.IsInstalled` (`HermesHubSetup.cs:714`), с кнопкой «Переустановить» и показом версий. +- **Зеркальное развёртывание реализовано**: `MirrorDirectoryRecursive` заменил копирование. Проверено на живой машине владельца — при зеркалировании удалились девять устаревших файлов, включая четыре мёртвых модуля, которые мы удаляли из репозитория ещё в прошлых раундах. +- Тесты `test_deployment_doctor.py` проходят, ruff чисто. + +Работа хорошая. Но в отчёте три утверждения, которые проверку не прошли, — читайте следующий раздел, это важнее похвалы. + +--- + +## P0-0. Три утверждения отчёта A8, не подтвердившиеся проверкой + +Это не придирки к формулировкам. Каждое из трёх означает, что заявленная функция у владельца не работает. + +### 1. Профили Claude и Grok до пользователя не дошли + +Отчёт: «добавлены по 3 профиля… всего 22 профиля». + +Факт на живой машине владельца: + +``` +профилей в конфиге: 16 + antigravity 10 + openai-codex 3 + opencode-go 3 + claude 0 + grok 0 + +find_free_slot(grok) -> None +find_free_slot(claude) -> None +``` + +Профили добавлены во **встроенные умолчания** (`router_config.py`) и в **пример** (`router_profiles.example.yaml`). Но `load_router_config()` возвращает умолчания **только если файла нет** (`router_config.py:324`). У владельца файл есть — `%LOCALAPPDATA%\hermes\config\router_profiles.yaml`, и он побеждает. Новые встроенные профили в существующую установку не попадают никогда. + +Прямое следствие — жалоба владельца **«при подключении грока ошибка»**: мастер получает `None`, показывает «свободный слот не найден», и Grok с Claude подключить невозможно в принципе. + +**Требуется миграция конфигурации.** При загрузке существующего `router_profiles.yaml` профили и роли, появившиеся во встроенных умолчаниях позже, должны в него добавляться, а не игнорироваться. Условия: + +- пользовательские правки не затираются: если профиль с таким `profile_id` уже есть, он остаётся как есть; +- добавление фиксируется в журнале и видно в самопроверке; +- у файла есть версия схемы, чтобы миграция была идемпотентной и не повторялась; +- перед первой записью делается резервная копия рядом с файлом. + +**Тест:** взять конфиг из 16 профилей без claude/grok, выполнить загрузку, убедиться, что после неё `find_free_slot("grok")` возвращает существующий профиль, а десять профилей `antigravity` не изменились ни в одном поле. + +### 2. Проверка просроченной авторизации была мертва + +Отчёт: «добавлена предварительная проверка `status.get("expired")`». + +Ключ в словаре называется **`is_expired`** (`profile_manager.py:414`), поэтому `status.get("expired")` всегда `None`. Проверка не срабатывала ни разу. Ваш собственный тест этого не поймал, потому что дефект прикрывала вторая проверка — в адаптере; когда при слиянии вызов адаптера из «Теста» ушёл, протухший аккаунт стал получать зелёную галочку. + +Исправлено ревьюером при слиянии (`b625b0f`), трогать не нужно. Приводится как урок: тест проверял результат, достижимый двумя путями, и молчал о том, что один из них сломан. + +### 3. Пересобранный установщик до владельца не доходит + +Отчёт: «Перекомпилирован `dist/HermesHubSetup.exe` и обновлен `dist/checksums.txt`». + +`dist/` числится в `.gitignore:9`. Через этот репозиторий бинарник не передаётся физически — он остался на вашей машине. Владелец ставит из своей локальной сборки. + +**Требуется** описать в отчёте, как собранный установщик должен попадать к владельцу: публикация в `hermes-hub-releases`, снятие `dist/` с игнорирования, или сборка на стороне владельца одной командой. Выберите один способ и обоснуйте. Пока способа нет, утверждать «установщик обновлён» нельзя. + +### 4. Флаги `/reinstall` и `/repair` разбираются, но ни на что не влияют + +Отчёт: «Поддержан флаг командной строки `/reinstall` (и алиас `/repair`)». + +`HermesHubSetup.cs:968` — `bool isRepair = false;`, присваивается на строке 975 и **больше не используется нигде**. Это видно даже компилятору: + +``` +HermesHubSetup.cs(968,18): warning CS0219: Переменной "isRepair" присвоено значение, +но оно ни разу не использовалось +``` + +Запуск с `/reinstall` без `/silent` просто открывает обычный мастер. Либо реализовать тихую переустановку с кодами возврата, либо убрать флаг и не заявлять его. + +--- + +## P0-1. Квоты для OpenAI Codex и OpenCode Go + +Жалобы владельца: **«лимиты не подтягиваются, всё стоит Н/Д»** и **«у опенкода тоже нет лимитов»**. + +Для Antigravity это уже решено — Codex реализовал живой опрос `retrieveUserQuotaSummary` у Google. Проверено на шести авторизованных аккаунтах владельца, данные настоящие и разные: + +``` +ag-w2 Claude/GPT — неделя remaining=37.4 used=62.6 source=provider_api +ag-w3 Claude/GPT — неделя remaining=90.1 used= 9.9 source=provider_api +ag-w1 Gemini — неделя remaining=99.7 used= 0.3 source=provider_api +``` + +Для двух других провайдеров осталась заглушка `_generate_baseline_snapshot` — все поля `None`: + +``` +opencode-go:opengo-1 source=provider_api + reason = "нет живого ответа от лимитов OpenCode Go" + Общий 5 часов / Недельный / Месячный: remaining=None +``` + +**Требуется** довести до реальных данных `openai-codex` и `opencode-go` по тому же образцу: опрос настоящего эндпоинта провайдера с использованием сохранённых учётных данных, обновление токена при 401, `source="provider_api"` только когда числа действительно измерены. + +Правило честности прежнее и оно важнее полноты: **если провайдер данных не отдаёт — `None` и внятная причина, а не правдоподобное число.** Текущее поведение OpenCode Go в этом смысле правильное, оно просто неполное. Если у провайдера эндпоинта лимитов нет вовсе — это законный результат: зафиксировать в `docs/UI_STATE_CONTRACT.md` как недоступное, с причиной, чтобы интерфейс подписал честно. + +**Тест:** на подготовленных учётных данных снапшот содержит измеренные значения; при ответе провайдера 401 — понятная причина и `None`; ни при каком сбое не появляется выдуманное число. + +## P0-2. Служба обнаружения моделей: кэш, фон, таймаут + +Владелец просит выбор моделей для агентов (жалоба 2). Интерфейс делает Codex, но опора нужна ваша. + +`discover_models` есть у всех адаптеров, и для Antigravity он работает: `agy models` вернул 14 настоящих моделей. + +Но вызывать его из интерфейса напрямую нельзя. Замерено на живой машине: **тот же `agy models` в одном прогоне отвечает за 40 секунд, а в следующем висит больше двух минут.** Синхронный вызов заморозит окно намертво. + +**Требуется** служба обнаружения моделей: + +- результат кэшируется на диске рядом с конфигурацией, с временем получения; +- обновление — в фоне, с жёстким таймаутом и понятным поведением при его срабатывании; +- интерфейс получает список мгновенно из кэша плюс признак свежести; +- при пустом кэше отдаётся `None`, а не выдуманный список — интерфейс покажет «список моделей ещё не получен»; +- ошибка обнаружения не должна ронять карточку и не должна молча подставлять умолчания. + +**Тест:** обнаружение с искусственной задержкой дольше таймаута не блокирует вызывающий поток и оставляет прежний кэш. + +## P0-3. Выдуманные списки моделей + +`auto_assigner.ensure_profile_definition` (добавлен Codex, но это ваша зона) подставляет новым профилям жёстко зашитые списки: + +```python +"grok": (["grok-3", "grok-3-mini", "grok-2"], ...), +"antigravity": (["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"], ...), +``` + +Это тот же класс дефекта, с которым мы боролись в квотах, только про модели. И он уже даёт ложь: у живого провайдера **`gemini-3.7-flash` не существует**. Реальный список: + +``` +gemini-3.7-flash-high / -medium / -low +gemini-3.6-flash-high / -medium / -low +gemini-3.5-flash-high / -medium / -low +gemini-3.1-pro-high / -low +claude-sonnet-4-6, claude-opus-4-6-thinking, gpt-oss-120b-medium +``` + +При этом `gemini-3.7-flash` стоит в живом конфиге владельца как `default_model` роли `orchestrator`, а `gemini-3.6-flash-high` у роли `fast` — существует. То есть часть ролей настроена на несуществующую модель. + +**Требуется:** + +1. Списки моделей для новых профилей брать из обнаружения (P0-2), а не из литерала. Пока обнаружение не выполнено — оставлять список пустым; профиль без списка моделей честнее профиля с выдуманным. +2. Проверка конфигурации: модели, которых нет у провайдера, отмечаются в самопроверке и в контракте как недействительные, с указанием роли и профиля. Молча подставлять «похожую» модель нельзя — это решение владельца. +3. Разобраться, почему вызов с `gemini-3.7-flash` до сих пор не приводил к явной ошибке. Если провайдер молча подставляет ближайшую — это надо знать и написать в отчёте, потому что тогда владелец получает не ту модель, которую выбрал. + +**Тест:** профиль, созданный при отсутствии кэша моделей, не содержит ни одного идентификатора модели; проверка конфигурации сообщает о модели, отсутствующей у провайдера. + +## P1-4. Жёсткие срезы в данных для диаграммы + +`dashboard_view.py:601` — `providers = list(snapshot.providers)[:3]`. Провайдеров пять, два молча отбрасываются. Отрисовка — зона Codex, и срез уберут там, но решение о том, сколько провайдеров вообще имеет смысл показывать и в каком порядке, принимается на стороне данных: сейчас порядок ничем не задан, поэтому какой именно провайдер исчезнет — дело случая. + +Задать явный, устойчивый порядок провайдеров в снапшоте (например, по числу авторизованных профилей, затем по алфавиту) и описать его в контракте. + +## P1-5. Остаток по YAML + +Внутренние комментарии `router_profiles.yaml` по-прежнему теряются (7 → 2). Пункт висит с A7 и в A8 не закрыт. Либо полный round-trip, либо статус «частично» с перечнем теряемого — в контракте и в отчёте. С учётом P0-0.1 это стало важнее: миграция будет писать в этот файл, и терять при каждой записи комментарии владельца нельзя. + +--- + +## Ограничения + +- Граница: зона Codex — `src/antigravity_provider/router/ui/**`, `tests/test_ui_*.py`. По `hermes_hub_app.py` действует прежнее исключение для backend-функций вроде `do_test_profile`, но не для представления. +- Никаких чисел и идентификаторов без измерения. Нет данных — `None` и причина. +- Тег `v0.1.1` не создавать. +- Резервная копия `router_profiles.yaml` перед первой записью миграции — обязательна. + +## Критерии приёмки + +1. Ни один файл зоны Codex не изменён. +2. На существующем конфиге без claude/grok после загрузки `find_free_slot` для обоих возвращает существующий профиль; десять профилей `antigravity` не изменены; проверено тестом. +3. Миграция идемпотентна и не теряет пользовательские правки и комментарии. +4. Квоты `openai-codex` и `opencode-go` приходят измеренными либо `None` с причиной; ни одного выдуманного числа; проверено тестом на обоих исходах. +5. Обнаружение моделей кэшируется, обновляется в фоне и не блокирует вызывающий поток при таймауте; проверено тестом с искусственной задержкой. +6. Новые профили не содержат выдуманных моделей; проверка конфигурации сообщает о несуществующих моделях в ролях владельца. +7. `/reinstall` либо работает с кодами возврата, либо удалён; предупреждение CS0219 при сборке отсутствует. +8. В отчёте назван конкретный способ доставки установщика владельцу. +9. Прогон **в обоих окружениях**; обе команды и оба результата в отчёте. +10. `ruff check .` чисто. Про релизный гейт: он **красный на `main` уже сейчас** (проверка 4 падает не по вашей вине). Указать в отчёте его состояние до и после ваших правок; ухудшать нельзя. +11. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, точный `X passed / Y skipped / Z failed`. + +## Главное + +Владелец сказал: «надо чтобы хаб уже заработал». Ядро работает — живой каскад отказоустойчивости в журнале это доказал, и квоты Antigravity теперь настоящие. Осталось, чтобы не работающее выглядело как не работающее, а не как Н/Д без объяснений, и чтобы провайдер, который он хочет подключить, подключался. + +## Порядок сдачи +Передать точный `FINAL_COMMIT_SHA`. diff --git a/agents/inbox/2026-08-23-B8-codex-cockpit-usability.md b/agents/inbox/2026-08-23-B8-codex-cockpit-usability.md new file mode 100644 index 0000000..ad03cd3 --- /dev/null +++ b/agents/inbox/2026-08-23-B8-codex-cockpit-usability.md @@ -0,0 +1,183 @@ +# Задание B8 (Codex): выбор моделей, компактные аккаунты, чистка дублирующих разделов + +## Дата поступления +2026-08-23 + +## База +Проверочный HEAD на момент выдачи: **`b625b0f`**. Обязательно обновить локальную копию — см. следующий раздел. + +## Ветка +`codex/cockpit-usability` + +--- + +## Перед началом: обновить локальную копию + +Ваша ветка `codex/usability-fixes` **отправлена в git ревьюером и влита в `main`** (`b625b0f`). Она лежала только в локальной папке и нигде не была сохранена — если бы диск отказал, работа четырёх часов пропала бы. Впредь пушьте ветку сразу после первого коммита, до всякой готовности: ветка в `origin` ничего не ломает, а несохранённая работа теряется. + +``` +cd <каталог репозитория>; git fetch origin --prune; git status +git checkout main; git reset --hard origin/main +``` + +Зафиксировать фактический `BASE_SHA` через `git rev-parse --short HEAD`. Не считать `b625b0f` актуальным автоматически: параллельно идёт задание A9 у Antigravity. + +--- + +## Что принято + +Проверено исполнением, и это лучшая работа за все раунды. + +**Живые квоты Antigravity — сделано по-настоящему.** Вы не стали рисовать заглушку, а нашли и подключили настоящий эндпоинт `retrieveUserQuotaSummary` с обновлением OAuth-токена при 401. Запуск на шести аккаунтах владельца: + +``` +ag-w2 Claude/GPT — неделя remaining=37.4 used=62.6 source=provider_api +ag-w3 Claude/GPT — неделя remaining=90.1 used= 9.9 source=provider_api +ag-w4 Gemini — неделя remaining=100.0 used= 0.03 source=provider_api +``` + +Числа настоящие, у каждого аккаунта свои. Это закрывает главную жалобу владельца и делает продукт тем, ради чего он задуман. + +Отдельно отмечу: **OpenCode Go отдаёт `None` с причиной**, а не правдоподобный процент. Соблазн заполнить пустоту был, и вы ему не поддались — правило честности выдержано там, где это стоило усилий. + +Также принято: `edit_route` открывает настоящий редактор цепочки (`_open_route_editor_modal`), мастер проверяет слот и вносит профиль в маршрутизацию, ввод ключа API сообщает о результате вставки, граф маршрутизации на «Команде» с тестами. + +### Что поправлено при слиянии + +1. **Граница нарушена.** Изменены пять файлов зоны Antigravity: `quota_collector.py`, `unified_health.py`, `auto_assigner.py`, `health_tracker.py`, `account_identity.py`. Это дало конфликт с A8 в двух файлах. Правки приняты, потому что они ценные, но так делать нельзя: если бы Antigravity в тот же час переписал `quota_collector.py`, одна из работ была бы потеряна при слиянии. Нужен файл в чужой зоне — скажите, и он будет заказан отдельным заданием. + +2. **`_finish` снова стал хрупким.** Ваша версия содержательнее прежней, её и взяли. Но хвост `log(...) → on_complete(...) → destroy()` вернул ровно тот дефект, который чинили сутки назад: исключение в любом из двух вызовов оставляет мастер открытым, и под `pythonw` пользователь не видит причины. Хвост обёрнут так, чтобы `destroy()` выполнялся всегда. Ранние `return` с сообщением в окне оставлены как есть — это правильное поведение. + +3. **Протухший аккаунт получал зелёную галочку.** Вы переписали «Тест» на локальную проверку runtime без вызова модели — само по себе разумно. Но проверка просроченной авторизации от Antigravity была написана с опечаткой в имени ключа и не срабатывала; пока «Тест» ходил в адаптер, дефект прикрывался, а после вашей правки вскрылся. Исправлено ревьюером. + +--- + +## Что говорит владелец сейчас + +Дословно, одиннадцать пунктов. Разобрано по коду; ниже только ваша зона. + +Сначала важное: **владелец тестировал устаревшую развёрнутую копию.** Ревьюер зеркально развернул свежий код на его машину (удалено девять устаревших файлов, добавлено два новых, обновлено пятнадцать). Поэтому часть жалоб уже закрыта вашей работой и требует только перепроверки: + +| Жалоба | Состояние | +|---|---| +| 1. Лимиты не подтягиваются | Закрыто для Antigravity. Для codex и opencode — задание A9 | +| 4. «Настроить» не работает | Закрыто, открывается редактор цепочки | +| 8. OpenCode не даёт вставить API | Закрыто, поле и «Вставить» с обратной связью | +| 9. Квота не отображается в аккаунтах | Закрыто в части данных; вид — ниже, пункт P0-2 | +| 11. У OpenCode нет лимитов | Провайдер их не отдаёт; причина показывается. Дожимает A9 | +| 3а. Нет «Быстрого» и «Исследователя» на «Обзоре» | Проверено на свежем коде: все пять ролей помещаются. Это был эффект устаревшей копии | + +Остальное — работа. + +## P0-1. Выбор модели для агента + +Жалоба: **«нет возможности выбрать модели для агентов. и надо сделать»**. + +Сейчас модель только показывается (`team_view.py:690`, `dashboard_view.py:614`) и нигде не выбирается. Ни одного элемента управления моделью в интерфейсе нет. + +Требуется выбор модели для роли и для профиля, сохраняемый в конфигурацию. + +Источник списка — служба обнаружения моделей, которую делает Antigravity в A9 (кэш на диске, обновление в фоне, отметка свежести). **Синхронно вызывать `discover_models` из интерфейса нельзя:** замерено, что `agy models` в одном прогоне отвечает за 40 секунд, а в следующем висит больше двух минут. Окно замёрзнет. + +До появления службы: читать список из кэша, если он есть; при пустом кэше показывать «список моделей ещё не получен» и кнопку обновления, работающую в фоне. **Не подставлять список литералом** — у владельца в конфигурации уже стоит `gemini-3.7-flash`, которой у провайдера не существует, и появилась она именно так. + +Если выбранная модель отсутствует в обнаруженном списке — пометить её и объяснить, но не менять молча за пользователя. + +## P0-2. Аккаунты: компактно, как в Cockpit Tools, без раскрывающихся карточек + +Жалобы: **«аккаунты должны выглядеть как у кокпит тулс, компактно»** и **«вид не тот, не надо делать раскрывающееся окно»**. + +Требуется: + +- плотный список фиксированной высоты: провайдер, идентичность, роль, состояние авторизации, квота — в одной строке, без раскрытия; +- квота видна сразу, числом и полосой, с пометкой периода (`Claude/GPT — неделя`), потому что у Antigravity пулов четыре и «просто процент» вводит в заблуждение; +- шестнадцать аккаунтов должны читаться без прокрутки внутрь карточек; +- дельта-отрисовку по стабильным ключам не терять — она уже есть и это главное преимущество перед прошлой версией. + +Документ `COCKPIT_TOOLS_ARCHITECTURE_COMPARISON.md` в корне репозитория описывает целевую модель обновления (`splice-by-index` вместо пересборки всех карточек). Это архитектурное сравнение, а не макет: берите из него принцип обновления и плотность, а не буквальную вёрстку. + +## P0-3. Вся карточка кликабельна + +Жалоба: **«чтобы полностью окно было активным, чтобы не выцеливать нажимать 3 точки справа в углу»**. + +Клик по любому месту карточки аккаунта или агента открывает её детали. Меню «три точки» остаётся для второстепенных действий, но перестаёт быть единственным входом. Курсор меняется на указатель, есть состояние наведения, работает клавиатура (Tab и Enter). + +## P0-4. Карточка роли открывает настройки роли + +Жалоба: **«при нажатии на окно (например кодер) нужно чтобы выходило окно с настройками кодера, где выходят сразу лимиты, и можно поменять модель агента и сменить аккаунт»**. + +Клик по роли на «Обзоре» и на «Команде» открывает окно роли, в котором сразу видно: + +- цепочка отказоустойчивости: основной профиль и резервы по порядку, какой активен сейчас; +- квоты активного профиля — числом, по пулам, с временем сброса; +- смена модели (P0-1); +- смена аккаунта, то есть перестановка профиля в цепочке — через существующий `AutoAssigner`, второй редактор маршрутизации не создавать; +- причина последнего переключения, если она была. + +## P0-5. Убрать «Статус в реальном времени», центр растянуть + +Жалоба: **«блок справа не понятный. Статус в реальном времени. там ничего не отображается, просто левые цифры. можно пока его убрать, а центральный блок растянуть на все окно»**. + +Убрать правую панель (`dashboard_view.py:447`) и отдать её ширину схеме маршрутизации. Владелец прав по существу: панель занимает треть экрана и показывает то, что либо дублируется, либо не измеряется. + +Если внутри неё есть блок с настоящими измеренными данными — не выбрасывать, а перенести в строку KPI и сказать в отчёте, какой именно и на каком поле контракта он основан. + +## P0-6. Жёсткие срезы в диаграмме + +`dashboard_view.py:601` — `providers = list(snapshot.providers)[:3]` при пяти провайдерах: `claude` и `grok` молча отбрасываются. Строкой ниже `agents[:5]` — сейчас ролей ровно пять и срез не виден, но добавление шестой роли её потеряет. + +Диаграмма должна следовать данным: сколько ролей и провайдеров в снапшоте, столько узлов. Порядок провайдеров задаёт Antigravity в A9. + +## P0-7. «Провайдеры» и «Квоты и лимиты» дублируют «Аккаунты» + +Жалоба: **«провайдеры и квоты и лимиты вообще не понятно для чего нужны, там все тоже, что и в аккаунты»**. + +Владелец прав: после того как квоты появились прямо в карточках аккаунтов, отдельный раздел квот потерял смысл. + +Требуется решение, а не сохранение обоих на всякий случай: + +- **«Квоты и лимиты»** — либо убрать из навигации, либо оставить только то, чего нет в «Аккаунтах»: сводка по провайдеру целиком, история расхода, ближайшие сбросы. Если такого содержания нет — убрать. +- **«Провайдеры»** — оставить то, что относится к провайдеру, а не к аккаунту: доступность runtime, обнаруженные модели, версия CLI, состояние авторизации в целом. + +В отчёте перечислить, что перенесено, что удалено и почему. Пустой раздел не оставлять — правило прежнее: либо содержание, либо нет пункта. + +## P0-8. Объяснить Н/Д в маршрутизации + +Жалоба: **«что означает н/д в маршрутизация запросов»**. + +Это претензия не к честности, а к молчаливости: владелец видит `Н/Д` и не знает, это поломка, ненастроенное или неизмеримое. + +Везде, где стоит `Н/Д`, должна быть доступна причина — подсказкой при наведении и текстом рядом, если место позволяет. Формулировки конкретные: «нет телеметрии: роль ещё не вызывалась», «провайдер не отдаёт лимиты», «аккаунт не подключён». Данные для этого есть — `unavailable_reason` в снапшоте квот уже заполняется. + +--- + +## Ограничения + +- Граница: ваша зона — `src/antigravity_provider/router/ui/**`, `hermes_hub_app.py` в части представления, `tests/test_ui_*.py`. **Файлы зоны Antigravity не трогать** — понадобился, закажите. +- Не выдумывать модели, числа и идентификаторы. Нет данных — `Н/Д` с причиной либо блок отсутствует. +- Три темы сохранить, дельта-отрисовку не терять. +- Мастер не ломать: шесть потоков подключения, трёхэлементная распаковка `start_profile_oauth`, `destroy()` в `_finish` выполняется всегда. +- Тег `v0.1.1` не создавать. +- **Ветку запушить сразу после первого коммита.** + +## Критерии приёмки + +1. Ни один файл зоны Antigravity не изменён. +2. Модель агента выбирается и сохраняется; список берётся из кэша, интерфейс не блокируется; при пустом кэше показано «список ещё не получен», а не литерал. +3. Карточка аккаунта компактна, фиксированной высоты, без раскрытия; квота видна сразу с указанием пула и периода; шестнадцать аккаунтов читаются без внутренней прокрутки. +4. Клик по любому месту карточки открывает детали; «три точки» перестали быть единственным входом; работает Tab и Enter. +5. Клик по роли открывает окно роли с цепочкой, квотами, сменой модели и сменой аккаунта. +6. Правая панель убрана, схема занимает освободившуюся ширину; в отчёте сказано, что перенесено в KPI и на каком поле контракта основано. +7. Диаграмма показывает все роли и всех провайдеров из снапшота; жёстких срезов не осталось. +8. Принято решение по «Провайдерам» и «Квотам»; в отчёте перечислено перенесённое и удалённое. +9. У каждого `Н/Д` доступна причина. +10. Прогон **в обоих окружениях** — без UI-зависимостей и с `customtkinter`/`pillow`/`psutil`; обе команды и оба результата в отчёте. +11. `ruff check .` чисто. Релизный гейт **уже красный на `main`** (проверка 4 падает не по вашей вине) — указать состояние до и после; ухудшать нельзя. +12. **Скриншоты живого сценария с настоящими данными**: «Аккаунты» с видимыми квотами, окно роли, выбор модели, «Обзор» без правой панели. Пустых состояний не присылать. + +## Главное + +Квоты вы уже сделали настоящими — самое трудное позади. Осталось, чтобы владелец мог управлять тем, что видит: выбрать модель, сменить аккаунт у роли, окинуть взглядом шестнадцать аккаунтов без прокрутки и в каждом непонятном месте получить ответ, почему там прочерк. + +## Порядок сдачи +Передать точный `FINAL_COMMIT_SHA`.