# Контракт веб-интерфейса Hermes Hub

Версия контракта: **1.4**
Дата: 2026-08-24

Этот документ — **единственный** источник истины для двух сторон: серверной (задание A15) и клиентской (A16). Обе стороны разрабатываются параллельно и до слияния друг друга не видят.

Правило, купленное дорого: в прошлом раунде служба обнаружения моделей была написана правильно, но названа `model_discovery_service.py`, тогда как потребитель импортировал `model_discovery`. Импорт был обёрнут в `except ImportError`, поэтому расхождение **не дало ошибки** — выбор моделей просто остался пустым навсегда. Здесь то же самое повторится в виде «страница грузится, но данных нет», если стороны разойдутся хоть в одном имени.

Поэтому: **любое отклонение от этого документа — дефект, даже если код работает.** Меняется контракт — сначала правится документ и согласуется с обеими сторонами.

---

## 1. Стек и его обоснование

**Сервер:** FastAPI + uvicorn. Обе зависимости уже объявлены в `pyproject.toml` в группе `legacy` — она осталась от удалённого `gui_server.py` и сейчас не используется ничем. Переименовать группу в `web` и подключить.

**Клиент:** обычный JavaScript и `fetch`, **без сборки, без npm, без фреймворка**.

Обоснование, а не вкусовщина. Проект ведут агенты на трёх разных машинах; любой шаг сборки означает согласование версий Node, установку зависимостей и дрейф lock-файлов между машинами. Интерфейс и так был единственным узким местом всех прошлых раундов. Страница, которую можно открыть файлом и отладить в браузере без инструментов, снимает целый класс проблем. React в этом проекте отклонён и раньше — по той же причине.

## 2. Границы и структура разделов

Веб-интерфейс **дополняет** десктоп, а не заменяет его немедленно. Десктопное приложение обязано продолжать работать до тех пор, пока веб не достигнет паритета. Ничего из `router/ui/**` не удаляется в рамках этих заданий.

Веб-клиент организован в **7 функциональных разделов** (в порядке меню навигации):
1. **`overview` (Обзор)** — Сводные счетчики слотов и подключений провайдеров, схема маршрутизации и выбор моделей ролей прямо на схеме.
2. **`accounts` (Аккаунты)** — Список подключенных профилей, статусы авторизации и оперативные квоты.
3. **`routing` (Маршрутизация)** — Главный центр управления: перетаскивание узлов Drag-and-Drop, выбор модели на узле, добавление (+ Добавить) и удаление (✕) профилей из цепочек.
4. **`analytics` (Аналитика)** — Телеметрия вызовов и задержек за 24ч (p50/p95/max), пояснение fast-fail и честное отображение неподключенных провайдеров.
5. **`health` (Состояние)** — Готовность системы, мониторинг системных ресурсов и рисков.
6. **`logs` (Журнал событий)** — Обратный хронологический журнал событий Hermes Hub.
7. **`settings` (Настройки)** — Параметры веб-сервера, токены, интервалы и системные пути.

Веб-слой живёт в пакете:

```
src/antigravity_provider/router/web/
    __init__.py
    server.py          # FastAPI-приложение и запуск uvicorn
    api.py             # эндпоинты
    security.py        # привязка к адресу, токен
    static/
        index.html
        app.js
        style.css
```

## 3. Безопасность — обязательная часть, не опция

Веб-интерфейс будет работать на сервере, доступном по сети. Требования:

1. **По умолчанию слушать только `127.0.0.1`.** Другой адрес — только явным параметром запуска, и при этом **обязателен токен**.
2. Токен передаётся заголовком `X-Hub-Token`, сравнивается через `secrets.compare_digest`. При запуске без токена на не-локальном адресе сервер **отказывается стартовать** с внятным сообщением, а не поднимается открытым.
3. **Секреты не покидают сервер ни при каких условиях.** Проверено на живых данных: сериализованный снапшот не содержит ни `access_token`, ни `refresh_token`, ни `api_key`, ни JWT. Это состояние обязано сохраниться — на стороне сервера нужен тест, который падает при появлении в ответе любого из этих ключей.
4. Почты аккаунтов в снапшоте присутствуют открытым текстом (43 вхождения на машине владельца). Это персональные данные, не учётные. Маскирование — решение владельца; по умолчанию отдаём как есть, поскольку интерфейс без идентичности аккаунта бесполезен.
5. Никаких секретов в URL и параметрах запроса.

## 4. Эндпоинты

### `GET /api/snapshot`

Возвращает всё состояние интерфейса одним объектом.

Формат — **точный результат `dataclasses.asdict(HubSnapshot)`** с сериализацией `datetime` в ISO-8601. Проверено исполнением: снапшот сериализуется одним вызовом, объём около 100 КБ.

Эталонный пример лежит рядом: **`docs/web-api/snapshot.example.json`** — снят с живой машины владельца, почты замаскированы. Клиентская сторона разрабатывается против этого файла и до готовности сервера в нём не нуждается.

Ключи верхнего уровня:

```
generation, seq, timestamp, profiles_by_provider, all_profiles,
readiness, agents, providers, routing, quotas, metrics, is_stale
```

**Обновление схемы ProfileViewModel:**
В рамках задачи A17 добавлены новые поля и состояния для отслеживания честного статуса:
- `health_state` может принимать значение `not_tested`, если профиль никогда не проверялся.
- Добавлено поле `last_success_at` (время последней успешной проверки, `%H:%M:%S`).

Ответ: `200` и JSON. При ошибках сборки — `503` с телом `{"error": "<описание проблемы>"}`.

### `POST /api/action`

Единая точка для всех действий. Тело:

```json
{ "action": "<имя>", "data": { ... } }
```

Имена действий берутся **ровно** из общего слоя `action_handler.py`:

Действия Agent Manager и Workflow (A30):

| Action | Назначение | Обязательные данные |
|---|---|---|
| `create_agent` | Создать логического агента, роль маршрутизатора и Agent File | `name`, `role`; опционально `profile_id`, `model`, настройки исполнения |
| `update_agent` | Изменить свойства и назначение Provider → Account → Model | `agent_id`; назначение задаётся `provider`, `profile_id`, `model` |
| `delete_agent` | Удалить агента; при ссылках сначала возвращает `confirmation_required` | `agent_id`; после подтверждения `force: true` |
| `read_agent_file` | Прочитать реальный Markdown Agent File | `agent_id` |
| `save_agent_file` | Атомарно сохранить Agent File для последующих запусков | `agent_id`, `content` |
| `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` |
| `start_workflow` | Запустить реальную задачу через RouterEngine | `task` |
| `stop_workflow` | Запросить остановку текущего запуска | — |
| `run_preflight` | Запустить zero-quota проверку зависимостей, CLI, Python окружения и локальных серверов | — |

Состояние графа и LIVE-журнал приходят в поле `workflow` ответа
`GET /api/snapshot`. `workflow.run.status=loading` означает загрузку;
`unavailable_reason` означает отсутствие данных с явной причиной. Показатели
workflow нельзя подменять фикстурой при недоступности API.

```
account_details   add_account       agent_settings    apply_update
assign_role       auto_assign_all   check_updates     delete_credentials
edit_route        get_update_status oauth             open_routing
refresh_account   refresh_all       refresh_data      refresh_models
reorder_chain     run_preflight     save_chain        save_settings
set_main          set_model         set_orchestrator  test
```


Ответ:

```json
{ "ok": true,  "message": "<текст для пользователя>", "data": { ... } }
{ "ok": false, "message": "<причина отказа по-русски>" }
```

**`ok: false` — это `200`, а не `4xx`.** Отказ действия — нормальный результат, а не ошибка протокола. `4xx` остаётся для неизвестного действия и непройденной авторизации.

- **`check_updates`** / **`apply_update`** / **`get_update_status`** (A27): проверка и установка обновлений из самой программы. `check_updates` опрашивает GitHub API релизов основного репозитория `ochenstarik-ui/hermes-hub` и сравнивает установленный коммит со сборкой последнего релиза. `apply_update` загружает установщик/пакет, проверяет sha256 по `checksums.txt` и запускает обновление с сохранением пользовательских данных и настроек.
- **`save_chain`** / **`reorder_chain`** / **`edit_route`** (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. `data: {"role_id": "<role_id>", "chain": ["<pid1>", "<pid2>", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`.
- **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "<pid>", "role_id": "<role_id>", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`.
- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.
- **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом.


**`start_device_auth` / `poll_device_auth`** (добавлено при разборе жалобы «не даёт зайти в грок»): авторизация по коду устройства для Grok и OpenAI Codex прямо из веба. Раньше поток был только в десктопе, а веб-мастер показывал заглушку.

`start_device_auth` — `data: {"provider": "grok"|"openai-codex"}`. Возвращает `{"session_id", "url", "code", "profile_id"}`. **Адрес и код выдаёт провайдер**; интерфейс их только отображает и не имеет права подставлять свои.

`poll_device_auth` — `data: {"provider", "session_id"}`. `ok: true` со `status: "pending"` означает ожидание подтверждения; `status: "completed"` — аккаунт подключён; `ok: false` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.

**`start_redirect_auth` / `submit_redirect_callback` / `poll_redirect_auth` / `cancel_redirect_auth`** (добавлено при разборе жалобы «не даёт добавлять аккаунты на линукс»): вход по ссылке для **Antigravity** и **Claude**.

Раньше веб-мастер утверждал, что вход через веб «невозможен», и отправлял в консоль по SSH, а `GET /api/health` отдавал для этих двух провайдеров жёстко вписанное `supported: false`. Утверждение неверно: `ProfileOAuthSession.handle_manual_callback_url` и `ClaudeOAuthSession.handle_auth_code` принимают вставленное вручную значение. **Браузер нужен где угодно, а не на машине с Hub.**

`start_redirect_auth` — `data: {"provider": "antigravity"|"claude", "profile_id"?}`. Возвращает `{"session_id", "url", "port", "redirect_uri", "profile_id", "paste_kind"}`. `paste_kind` — `"url"` (Antigravity кладёт код в адресную строку) или `"code"` (Claude показывает код на странице); подсказка в интерфейсе обязана различаться, иначе владелец ищет не то. **Ссылку выдаёт провайдер**, интерфейс не имеет права подставлять свою.

`profile_id` передаёт **клиент**, а не подбирает сервер. `AutoAssigner.find_free_slot` определяет занятость по файлу учётных данных, но `agy` на Windows хранит их в keyring — файла нет ни у одного слота, поэтому все считаются свободными и всегда возвращается первый. Вход затёр бы работающий аккаунт. Слот выбирает владелец из списка, построенного по снапшоту.

`submit_redirect_callback` — `data: {"session_id", "provider", "callback_url"}`. Ошибки разбора (нет кода, чужой `state`) **не завершают сессию**: владелец переносит значение между машинами руками и легко промахивается, а ссылка остаётся годной. Конечны только отказ провайдера и отмена.

`poll_redirect_auth` — `data: {"session_id"}`. `status: "pending"` — ожидание; `"completed"` — подключено; `ok: false` — конечный отказ. Закрытие слушателя по таймауту (20 минут) **не** конечный отказ: вставить адрес вручную можно и после него.

### `GET /api/health`

`{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.
Поле `auth_flows` содержит информацию о доступности потоков авторизации на сервере. Формат:
```json
{
  "auth_flows": {
    "openai-codex": {"supported": true, "reason": "device-code"},
    "grok": {"supported": true, "reason": "device-code"},
    "antigravity": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"},
    "claude": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"}
  }
}
```
Веб-клиент **обязан** проверять это поле и не показывать неработающую кнопку, а предлагать обходной путь.


### `GET /api/events`

Возвращает список недавних событий системы из `EventLogService` в обратном хронологическом порядке.

Параметры запроса (Query Params):
- `limit` (int, необязательно, по умолчанию `50`, максимум `200`): количество событий;
- `category` (string, необязательно): фильтрация по категории (`account`, `quota`, `routing`, `auth`, `system`).

Ответ:
```json
{
  "events": [
    {
      "timestamp": "22:50:14",
      "category": "system",
      "message": "Успешная проверка подключения ag-w2 (gemini-3.1-pro-high) за 1.4s.",
      "details": "...",
      "level": "success"
    }
  ]
}
```
**Секреты в события не попадают.** На эндпоинт распространяется санитайзер `sanitize_snapshot`.

---

### `GET /api/settings`

Возвращает текущие настройки из `hub_settings.json` и пути системы.

**Секреты не отдаются:** поле `web_api_token` скрыто; наружу отдаётся только флаг `web_api_token_configured` (`true`/`false`).

Ответ:
```json
{
  "web_api_host": "127.0.0.1",
  "web_api_port": 5800,
  "web_api_token_configured": false,
  "theme": "system",
  "quota_refresh_interval_sec": 300,
  "hermes_home": "C:\\Users\\...\\.hermes",
  "config_dir": "C:\\Users\\...\\.hermes\\config",
  "log_file": "C:\\Users\\...\\.hermes\\logs\\hermes-hub.log"
}
```
Сохранение настроек выполняется через стандартное действие `POST /api/action` с `action: "save_settings"`.

---

### `GET /` и статика

**Сервер обязан отдавать сам интерфейс, а не только API.** Корневой маршрут возвращает `static/index.html`, каталог `static/` монтируется целиком.

Пункт добавлен постфактум: в версии 1.0 контракт описал каталог `static/` в структуре пакета, но не назвал, **кто его отдаёт**. Обе стороны выполнили написанное — API отвечал, файлы клиента лежали в репозитории — и всё равно не собрались: в браузере был `404`. Ответственность за пропуск на авторе контракта, а не на исполнителях.

Урок общего свойства: в контракте между двумя сторонами недостаточно описать, что каждая делает. Нужно назвать, **где именно они стыкуются** и кто отвечает за стык.

## 5. Обновление данных

Первая версия — **опрос**: клиент запрашивает `/api/snapshot` раз в 5 секунд и при действиях пользователя.

Поле `seq` в снапшоте монотонно растёт. Клиент обязан **игнорировать ответ с `seq` меньше уже применённого** — иначе поздний ответ затрёт свежий. Этот дефект в десктопе уже ловили, повторять не нужно.

Server-Sent Events — следующий шаг, в эти задания не входит. Закладывать под них структуру не нужно; опрос заменяется без переделки клиента.

## 6. Честность данных — правило распространяется на веб целиком

Прежнее и главное правило проекта действует без исключений:

- **ни одного числа, идентификатора или названия модели без измерения;**
- нет данных — «Н/Д» **и причина рядом**, доступная пользователю. Причина уже приходит в снапшоте полем `unavailable_reason`;
- **отличать «данных нет» от «данные ещё грузятся».** Сейчас это не различается, и владелец видел пустые карточки без объяснения — квота появляется только после фонового опроса. В вебе состояние загрузки обязано выглядеть как загрузка. Для этого в объект квоты (в `quotas` и связанных местах) добавлено поле `is_loading: bool`. Если `is_loading == true`, значит идёт опрос; если `false` и нет квот — значит данных действительно нет.

## 7. Что известно про Linux заранее

Проверено по коду, чтобы никто не выяснял это заново:

- `paths.py` уже кроссплатформенный: при отсутствии `LOCALAPPDATA` уходит в `~/.hermes`;
- поиск CLI провайдеров уже готов: `shutil.which("agy") or shutil.which("agy.exe")`;
- **восемь мест дублируют логику `LOCALAPPDATA` в обход `paths.py`** и на Linux дадут неверные пути: `hermes_hub_app`, `router_config` (две штуки), `launcher_bootstrap`, `model_discovery_service`, `ui/assets`, `agy_subprocess`.

**Авторизация на headless-сервере** — ограничение, которое надо признать честно, а не обойти молча:

| Провайдер | Поток | На сервере без экрана |
|---|---|---|
| OpenAI Codex | device-code | **работает** — код вводится с любой машины |
| Grok | device-code | **работает** |
| Antigravity | redirect на localhost | **не работает** — редирект придёт на машину пользователя, а не сервера |
| Claude | redirect на localhost | **не работает** |

Для двух последних интерфейс обязан сказать об этом прямо и предложить обходной путь (проброс порта по SSH либо авторизация на десктопе с переносом профиля), а не показывать неработающую кнопку.
