hermes-hub/COCKPIT_TOOLS_ARCHITECTURE_COMPARISON.md
Hermes Team 6494c5198a docs(tasks): A9 и B8 по итогам живого прогона владельца
A9 (Antigravity): миграция профилей в существующий router_profiles.yaml —
корень жалобы «при подключении грока ошибка»; квоты для codex и opencode;
служба обнаружения моделей с кэшем и фоновым обновлением; отказ от
выдуманных списков моделей. Плюс четыре утверждения отчёта A8, не
подтвердившиеся проверкой: профили claude/grok до пользователя не дошли,
проверка is_expired была мертва, dist в .gitignore, флаг /reinstall
не используется.

B8 (Codex): выбор модели агента, компактные карточки аккаунтов без
раскрытия, кликабельная карточка целиком, окно настроек роли, снятие
правой панели, устранение жёстких срезов диаграммы, решение по
дублирующим разделам, причина у каждого Н/Д.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:20:23 +07:00

1137 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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 105107 (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 аккаунтах: ~240320 widget операций per refresh.
При 50 аккаунтах: **~7501000 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<void>;
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<void>; // invoke('list_*_accounts')
runCurrentRefresh: () => Promise<void>; // 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<void> | 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<void> | 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("<B1-Motion>")` + ручное перемещение — ~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<void> | 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 = логический вывод из видимых паттернов — явно помечен во всех местах.*