From a18195e4e86908016b0d91b38af916982f87be86 Mon Sep 17 00:00:00 2001 From: Viktor Buzin Date: Tue, 6 Oct 2026 13:22:45 +0300 Subject: [PATCH 1/8] chore: update ROADMAP.md --- docs/EPIC_Hercules_Studio/ROADMAP.md | 248 ++- docs/EPIC_Hercules_Studio/VERIFICATION.md | 102 + .../adr/0001-electron-over-tauri.md | 18 +- .../adr/0009-web-first-studio.md | 150 ++ .../Hercules.WebApi/Auth/ApiKeyMiddleware.cs | 37 + .../Auth/StudioSessionStore.cs | 135 ++ .../Hercules.WebApi/CodeRuns/SkillRunStore.cs | 171 ++ .../Hercules.WebApi/Config/WebApiConfig.cs | 13 + .../Controllers/CodeRunController.cs | 189 ++ .../Controllers/StudioController.cs | 93 + .../Logging/FileLoggerProvider.cs | 22 +- src/agent/Hercules.WebApi/Program.cs | 88 +- src/agent/Hercules.WebApi/appsettings.json | 15 + src/agent/Hercules.slnx | 1 + src/agent/Mesh/A2A/AgentCardService.cs | 20 +- src/hercules-studio/.gitignore | 7 +- src/hercules-studio/README.md | 387 +--- src/hercules-studio/electron-builder.yml | 46 - src/hercules-studio/electron.vite.config.ts | 54 - .../electron/main/connections.ts | 231 --- src/hercules-studio/electron/main/index.ts | 86 - src/hercules-studio/electron/main/ipc.ts | 190 -- src/hercules-studio/electron/main/license.ts | 37 - src/hercules-studio/electron/main/scanner.ts | 162 -- src/hercules-studio/electron/main/settings.ts | 56 - src/hercules-studio/electron/main/sqlite.ts | 95 - src/hercules-studio/electron/preload/index.ts | 60 - src/hercules-studio/package-lock.json | 1706 +++++++++++++++-- src/hercules-studio/package.json | 23 +- src/hercules-studio/playwright.config.ts | 27 +- src/hercules-studio/renderer/index.html | 17 +- .../renderer/public/icon-maskable.svg | 5 + src/hercules-studio/renderer/public/icon.svg | 4 + .../renderer/public/manifest.webmanifest | 28 + src/hercules-studio/renderer/public/sw.js | 98 + src/hercules-studio/renderer/src/App.vue | 168 +- .../renderer/src/assets/styles/global.css | 50 +- .../components/common/AddConnectionDialog.vue | 185 ++ .../src/components/common/LicenseDialog.vue | 6 +- .../src/components/common/ReauthDialog.vue | 70 + .../src/components/layout/Sidebar.vue | 2 +- .../src/components/layout/StatusBar.vue | 3 +- .../renderer/src/config/view-registry.ts | 18 +- src/hercules-studio/renderer/src/env.d.ts | 5 +- src/hercules-studio/renderer/src/i18n/en.json | 152 +- src/hercules-studio/renderer/src/i18n/ru.json | 152 +- src/hercules-studio/renderer/src/main.ts | 6 +- src/hercules-studio/renderer/src/mock-api.ts | 255 --- .../renderer/src/platform/capabilities.ts | 220 +++ .../renderer/src/platform/index.ts | 42 + .../renderer/src/platform/web.test.ts | 446 +++++ .../renderer/src/platform/web.ts | 760 ++++++++ .../renderer/src/sdk/client.ts | 348 +++- src/hercules-studio/renderer/src/sdk/types.ts | 64 + .../renderer/src/serviceWorker.ts | 34 + .../renderer/src/stores/connections.ts | 326 +++- .../renderer/src/stores/settings.ts | 46 +- .../renderer/src/stores/toast.ts | 7 +- src/hercules-studio/renderer/src/stores/ui.ts | 34 + .../renderer/src/test/setup.ts | 26 + .../renderer/src/views/AgentList.vue | 200 +- .../renderer/src/views/ChatView.vue | 245 +++ .../renderer/src/views/ConfigView.vue | 174 ++ .../renderer/src/views/ConsensusView.vue | 277 +++ .../renderer/src/views/MeshView.vue | 221 +++ .../renderer/src/views/SkillsView.vue | 306 +++ .../renderer/src/views/ToolsView.vue | 209 ++ .../renderer/src/views/WorkflowView.vue | 231 +++ src/hercules-studio/shared/protocol.ts | 204 -- src/hercules-studio/tests/e2e/smoke.spec.ts | 53 - src/hercules-studio/tests/e2e/studio.spec.ts | 632 ++++++ src/hercules-studio/tsconfig.json | 1 - src/hercules-studio/tsconfig.node.json | 18 - src/hercules-studio/tsconfig.web.json | 4 +- src/hercules-studio/vite.config.ts | 35 +- src/hercules-studio/vitest.config.ts | 3 +- .../Hercules.Supervisor.csproj | 23 + src/hercules-supervisor/ManagedAgent.cs | 401 ++++ src/hercules-supervisor/Program.cs | 129 ++ src/hercules-supervisor/README.md | 116 ++ .../SupervisorApiClient.cs | 132 ++ src/hercules-supervisor/SupervisorOptions.cs | 73 + src/hercules-supervisor/SupervisorService.cs | 226 +++ src/hercules-supervisor/appsettings.json | 35 + src/hercules-web/DEPRECATED.md | 73 + .../Auth/StudioSessionStoreTests.cs | 141 ++ .../Phase3Tests/AgentCardServiceTests.cs | 43 + 87 files changed, 9702 insertions(+), 2549 deletions(-) create mode 100644 docs/EPIC_Hercules_Studio/VERIFICATION.md create mode 100644 docs/EPIC_Hercules_Studio/adr/0009-web-first-studio.md create mode 100644 src/agent/Hercules.WebApi/Auth/StudioSessionStore.cs create mode 100644 src/agent/Hercules.WebApi/CodeRuns/SkillRunStore.cs create mode 100644 src/agent/Hercules.WebApi/Controllers/CodeRunController.cs create mode 100644 src/agent/Hercules.WebApi/Controllers/StudioController.cs delete mode 100644 src/hercules-studio/electron-builder.yml delete mode 100644 src/hercules-studio/electron.vite.config.ts delete mode 100644 src/hercules-studio/electron/main/connections.ts delete mode 100644 src/hercules-studio/electron/main/index.ts delete mode 100644 src/hercules-studio/electron/main/ipc.ts delete mode 100644 src/hercules-studio/electron/main/license.ts delete mode 100644 src/hercules-studio/electron/main/scanner.ts delete mode 100644 src/hercules-studio/electron/main/settings.ts delete mode 100644 src/hercules-studio/electron/main/sqlite.ts delete mode 100644 src/hercules-studio/electron/preload/index.ts create mode 100644 src/hercules-studio/renderer/public/icon-maskable.svg create mode 100644 src/hercules-studio/renderer/public/icon.svg create mode 100644 src/hercules-studio/renderer/public/manifest.webmanifest create mode 100644 src/hercules-studio/renderer/public/sw.js create mode 100644 src/hercules-studio/renderer/src/components/common/AddConnectionDialog.vue create mode 100644 src/hercules-studio/renderer/src/components/common/ReauthDialog.vue delete mode 100644 src/hercules-studio/renderer/src/mock-api.ts create mode 100644 src/hercules-studio/renderer/src/platform/capabilities.ts create mode 100644 src/hercules-studio/renderer/src/platform/index.ts create mode 100644 src/hercules-studio/renderer/src/platform/web.test.ts create mode 100644 src/hercules-studio/renderer/src/platform/web.ts create mode 100644 src/hercules-studio/renderer/src/serviceWorker.ts create mode 100644 src/hercules-studio/renderer/src/stores/ui.ts create mode 100644 src/hercules-studio/renderer/src/test/setup.ts create mode 100644 src/hercules-studio/renderer/src/views/ChatView.vue create mode 100644 src/hercules-studio/renderer/src/views/ConfigView.vue create mode 100644 src/hercules-studio/renderer/src/views/ConsensusView.vue create mode 100644 src/hercules-studio/renderer/src/views/MeshView.vue create mode 100644 src/hercules-studio/renderer/src/views/SkillsView.vue create mode 100644 src/hercules-studio/renderer/src/views/ToolsView.vue create mode 100644 src/hercules-studio/renderer/src/views/WorkflowView.vue delete mode 100644 src/hercules-studio/shared/protocol.ts delete mode 100644 src/hercules-studio/tests/e2e/smoke.spec.ts create mode 100644 src/hercules-studio/tests/e2e/studio.spec.ts delete mode 100644 src/hercules-studio/tsconfig.node.json create mode 100644 src/hercules-supervisor/Hercules.Supervisor.csproj create mode 100644 src/hercules-supervisor/ManagedAgent.cs create mode 100644 src/hercules-supervisor/Program.cs create mode 100644 src/hercules-supervisor/README.md create mode 100644 src/hercules-supervisor/SupervisorApiClient.cs create mode 100644 src/hercules-supervisor/SupervisorOptions.cs create mode 100644 src/hercules-supervisor/SupervisorService.cs create mode 100644 src/hercules-supervisor/appsettings.json create mode 100644 src/hercules-web/DEPRECATED.md create mode 100644 tests/Hercules.Agent.Tests/Auth/StudioSessionStoreTests.cs diff --git a/docs/EPIC_Hercules_Studio/ROADMAP.md b/docs/EPIC_Hercules_Studio/ROADMAP.md index 0b5ed29..38d8086 100644 --- a/docs/EPIC_Hercules_Studio/ROADMAP.md +++ b/docs/EPIC_Hercules_Studio/ROADMAP.md @@ -1,6 +1,31 @@ # Hercules Studio — Roadmap -> Связанные документы: [README.md](README.md), [SYSTEM-DESIGN.md](SYSTEM-DESIGN.md), [tasks/](tasks/) +> Связанные документы: [README.md](README.md), [SYSTEM-DESIGN.md](SYSTEM-DESIGN.md), [VERIFICATION.md](VERIFICATION.md), [tasks/](tasks/) + +> # План-факт +> +> **Планом-фактом является раздел [Миграция на web-first](#миграция-на-web-first-2026-10-06)**, +> утверждённый владельцем 2026-10-06 (см. [ADR-0009](adr/0009-web-first-studio.md)). +> +> **Разделы Stage 0–9 ниже — SUPERSEDED как план-факт.** Они сохранены только как источник +> функциональных требований. Не планируйте и не отмечайте работу в них — переносите +> требования в web-first-план. Подробности отмены по каждому Stage — в блоке +> [⛔ Stage 0–9 — SUPERSEDED](#️-stage-09--superseded-как-план-факт). + +> ⚠️ **Архитектура изменена 2026-10-06 — см. [ADR-0009](adr/0009-web-first-studio.md).** +> Ниже этапы описывают Stage 0–9 в прежней, Electron-ориентированной формулировке. +> Актуальное состояние миграции на web-first — в разделе +> [Миграция на web-first](#миграция-на-web-first-2026-10-06) ниже. Ключевые поправки: +> +> - Studio — **браузерное SPA**, не Electron-приложение. Агент раздаёт его на `/ui`. +> - Stage 0 больше не про «skeleton Electron + IPC», а про **платформенный слой** +> (`renderer/src/platform/`) вместо `window.studioAPI`. +> - «Agent Scanner» = A2A discovery (`/agent.manifest.json`), а не скан портов и процессов. +> - Локальный терминал (xterm.js) заменён потоком вывода от агента. +> - Restart/PID-реестр переехали в отдельный процесс `Hercules.Supervisor` +> (`src/hercules-supervisor`), а не в UI. +> - Этапы ниже сохраняются как источник функциональных требований (что строим), +> но порядок работ определяется миграционными фазами. ## Обзор @@ -16,6 +41,227 @@ Stage 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 --- +## Миграция на web-first (2026-10-06) + +> ### ⚠️ Не путать `Phase` и `Stage` +> +> - **Stage 0–9** (раздел ниже, `tasks/stage_00`…`stage_09`) — **продуктовый** план +> функциональностей. Он **далеко не выполнен**: Monaco и история/диффы (Stage 2), +> Vue Flow (Stage 4), MCP CRUD (Stage 5), дистилляция контекста и Postgres +> (Stage 6), консилиум-движок и BPMN-дизайнер (Stage 7–8), упаковка (Stage 9) — +> всё это **не реализовано**. +> - **Phase 0–6** (этот раздел) — **миграционные** фазы переезда на web-first. Они +> про то, *чем Studio является*, а не о том, *что Studio умеет*. +> +> ### ⚠️ Task-файлы Stage 0–9 устарели и не отражают сделанное +> +> Состояние чекбоксов на 2026-10-06: +> +> | Файл | done | open | +> |---|---|---| +> | `stage_00_skeleton` | 50 | 102 | +> | `stage_01` … `stage_09` | **0** | 243 | +> +> Это **не** означает, что ничего нет. Часть Stage 1–7 закрыта по факту миграции +> (сканирование агентов, чат, навыки, config, mesh, tools, консилиум реализованы +> во вью Studio), но соответствующие чекбоксы никогда не обновлялись. +> Числа выше — верхняя граница невыполненного, а не точный остаток. +> +> **Честный остаток** (что реально отсутствует в Studio): +> Monaco и история/диффы (Stage 2), Vue Flow (Stage 4), **MCP CRUD (Stage 5)**, +> дистилляция контекста и Postgres (Stage 6), движок консилиума и BPMN-дизайнер +> (Stage 7–8), упаковка (Stage 9). Полный список — в +> [`src/hercules-web/DEPRECATED.md`](../../src/hercules-web/DEPRECATED.md). +> +> Синхронизация чекбоксов с фактическим состоянием — отдельная задача; здесь она +> намеренно не выполнена, чтобы не заявлять невыполненную работу. +> +> **Проверка:** результаты всех прогонов и статус критериев приёмки — в +> [VERIFICATION.md](VERIFICATION.md). Он существует, чтобы результаты можно было +> **проверить**, а не только принять на слово. + +> **Граница цели — ⚠️ НЕ ПОДТВЕРЖДЕНА владельцем.** Вопрос «входит ли роадмап +> Stage 0–9 в цель?» был задан, но ответ пришёл **по таймауту с автовыбором +> рекомендованного варианта**, а не от владельца. Владелец его не подтвердил. +> +> Пока подтверждения нет, 243 открытые задачи Stage 0–9 формально остаются в области +> цели, и **завершённость цели не считается установленной**. +> +> Решения, которые владелец **подтвердил явно**: chat остаётся request-response; +> `hercules-web` объявляется deprecated, а не портируется (это закрывает критерий 9). +> +> Подробности и состояние работ — в [VERIFICATION.md](VERIFICATION.md#out-of-scope--- +> -not-yet-confirmed-by-the-owner) и +> [`src/hercules-web/DEPRECATED.md`](../../src/hercules-web/DEPRECATED.md). + +**Происхождение:** этот раздел заменяет Stage 0–9 как план-факт миграции, утверждён +владельцем 2026-10-06 вместе с решением «chat остаётся request-response» и +«`hercules-web` не портируется, а объявляется deprecated». Решения зафиксированы в +[ADR-0009](adr/0009-web-first-studio.md) и воспроизведены ниже; сами ADR и этот раздел +написаны в рамках той же миграции, поэтому независимого документа плана до них не +существует. + +Фактическое состояние миграции по фазам. Этапы Stage 0–9 ниже остаются источником +функциональных требований; ниже — порядок работ и прогресс. + +| Фаза | Содержание | Статус | +|---|---|---| +| **Phase 0** | Починка сборки: реальные зависимости в `package.json`, удаление Electron-конфигов, порт 4330 (4322 занят `hercules-web`) | ✅ готово | +| **Phase 1** | Платформенный слой `renderer/src/platform/` (`capabilities.ts` / `web.ts` / `index.ts`) вместо `window.studioAPI`; `mock-api.ts` и `shared/protocol.ts` удалены | ✅ готово | +| **Phase 1** | Хранение состояния: версионированные ключи `hercules-studio.*.v1`, одноразовая миграция с legacy-блоба, API-ключ не попадает в localStorage | ✅ готово | +| **Phase 2a** | Сессионная аутентификация на агенте: `StudioSessionStore`, `X-Session-Token` в `ApiKeyMiddleware`, `POST /api/studio/session` | ✅ готово | +| **Phase 2a** | Раздача SPA агентом на `/ui` (`UseDefaultFiles` + `UseStaticFiles`), `base: "/ui/"` во Vite | ✅ готово | +| **Phase 2a** | CORS-whitelist агента дополнен портом 4330 | ✅ готово | +| **Phase 2b** | Запуск кода в sandbox + SSE-поток результата: `POST /api/code/run`, `GET /api/code/run/{runId}/stream`, `GET /api/code/run/{runId}` | ✅ готово | +| **Phase 2b** | SSE-поток для чата | ✅ закрыто решением владельца — стриминг не делаем | +| **Phase 3** | `Hercules.Supervisor` (`src/hercules-supervisor`): health-цикл, `restart-pending`, PID-реестр на `GET /supervisor/agents` | ✅ готово | +| **Phase 4** | Замена placeholder-видов на реальные | ✅ 8 из 8 | +| **Phase 5** | Порт панелей `hercules-web` в Studio | ✅ закрыто решением владельца — порт не делаем | +| **Phase 6** | PWA: manifest + service worker | ✅ готово | + +### Что изменилось в смежных компонентах + +Агент: +- `Auth/StudioSessionStore.cs` — in-memory реестр сессий (TTL из `WebApi:StudioSessionTtlMinutes`). +- `Controllers/StudioController.cs` — `POST/GET/DELETE /api/studio/session`. +- `Controllers/CodeRunController.cs` + `CodeRuns/SkillRunStore.cs` — запуск кода в sandbox + с SSE-выводом и ограниченным буфером реплея (256 событий на прогон, TTL завершённых прогонов 15 мин). +- `Auth/ApiKeyMiddleware.cs` — приём `X-Session-Token` наравне с `X-Api-Key`. +- `Program.cs` — раздача `/ui`; CORS-порты 4330/8421. +- `Logging/FileLoggerProvider.cs` — `FileShare.ReadWrite` + проглатывание исключений логгера. + Без этого рестарт под supervisor'ом ронял новый процесс необработанным `IOException`. +- `Mesh/A2A/AgentCardService.cs` — `AgentCard.Endpoint` задаётся как URL-путь + (`/agent-card.json`), а `TrimStart('/')` превращал его в **относительный** путь ФС, + и карточка писалась в рабочий каталог процесса. Это, во-первых, засоряло репозиторий + (`src/agent/agent-card.json` при каждом старте), во-вторых, расходилось с читателем + `GET /agent-card.json` (ищет в dataRoot) — из-за чего endpoint отдавал 404 при + CWD ≠ dataRoot. Теперь относительные значения разрешаются от каталога манифеста, + как и обещает док-комментарий конфига. Покрыто тестами + `PublishAsync_ResolvesRelativeEndpointAgainstManifestDir` и + `PublishAsync_HonoursAbsoluteEndpoint` (существующий тест использовал абсолютный + `Endpoint` и баг не ловил — именно его форма и стоит в проде). +- Studio: `vue-i18n` поднят с 9 на **11** (v9/v10 больше не поддерживаются). + Приложение уже работало в composition-режиме (`legacy: false`), поэтому миграция + затронула только версию пакета; 12 E2E-тестов (включая «translations resolve») зелёные. + +> **Переименование относительно плана.** План предполагал `POST /api/skills/{id}/run`. +> По факту `Skill` (`Storage/Models.cs`) — это `Meta + Description + Prompt`, поля с кодом +> у него нет: навык исполняет LLM, а не sandbox. Прогон нацелен на *кусок кода* +> (file-based C#-навык под тестом или сниппет из редактора), поэтому эндпоинт — `/api/code/run`. + +Studio: +- `renderer/src/platform/` — контракт возможностей и браузерная реализация. +- Dev-порт 4330 (4322 занят `hercules-web`), `base: "/ui/"` — тот же путь, что и в проде. +- Диалог подключения вынесен на уровень приложения (`AddConnectionDialog`): пустое + состояние предлагает «Add connection manually», а `AgentList` на нём не смонтирован. +- `ChatView` — диалог с агентом, инкрементальный «печатающийся» вывод, переход к + навыку по предложению агента (через `stores/ui.ts`). +- `SkillsView` — список навыков, правка prompt, запуск кода в sandbox с выводом по SSE. +- `ConfigView` — правка живой конфигурации агента через merge `PATCH` (полная замена + `PUT` намеренно не выведена: это system-операция). Перезапуск доступен только при + system-роли сессии — Studio ставит флаг, рестарт делает supervisor. +- `MeshView` — реестр агентов с доверием, здоровьем и состоянием circuit breaker. + Загрузка частичная: падение одного под-запроса (`/api/mesh/denials`) не гасит + остальную таблицу. +- `ToolsView` — реестр инструментов с health, rate-limit и side-effect уровнем, + переключатель enable/disable. Переключение оптимистичное, но **откатывается**, + если агент отклонил изменение — иначе UI врал бы о состоянии инструмента. +- `ConsensusView` — очередь human-in-the-loop: согласования (`/api/approvals`) и + эскалации (`/api/escalations`) с approve/deny, фильтром по severity и batch-approve. + После любого действия очередь **перечитывается**, а не считается обновлённой локально. + Списки грузятся независимо: недоступность эскалаций не скрывает согласования. +- `WorkflowView` — определения workflow с **отдельного** workflow-сервера, у которого + своя аутентификация (`X-Client-Id`/`X-Client-Secret`, не сессия агента). Поэтому + `requiresConnection: false` — вью работает без подключённого агента. Credentials + хранятся только в памяти, по тем же основаниям, что и ключ агента. Кнопка Run + помечена как заглушка: `run`/`executions` на бэкенде не реализованы (task_105). +- PWA — `manifest.webmanifest` + иконки + service worker. Кэш намеренно консервативный: + навигация network-first с откатом на шелл, хэшированные ассеты cache-first, + `/api/*` **никогда** не кэшируется (устаревший `/api/config` или повторный обмен + сессией были бы откровенно вредны). Регистрация — `updateViaCache: "none"`, потому что + агент отдаёт `sw.js` как иммутабельный ассет и без этого браузер год не подхватил бы + обновление воркера. + +### Ловушка проверки: typecheck не ловит ошибки разбора шаблонов + +`npm run typecheck` (vue-tsc) прошёл для `ToolsView.vue`, а `vite build` упал на +`:placeholder="t("tools.search")"` — вложенные двойные кавычки обрывают значение +атрибута. Проверка `="[^"]*\bt\("` по шаблонам ловит это заранее. +**Перед тем как считать view готовым, запускайте `npm run build`, а не только typecheck.** +- Требует **system-role** API-ключ (`dev-system-key` в dev-конфиге). `contribute`-ключ + получает 403 на `/api/system/restart` — это ожидаемо. +- Статус API: `GET /supervisor/agents`, `POST /supervisor/agents/{name}/{start|stop|restart}`. +- Рестарт выполняется **ровно один раз** на запрос; восстановление после неудачного + старта — задача health-цикла (`AutoRestart`), а не повтор рестарта. + +### Решение владельца: SSE для чата НЕ делаем (2026-10-06) + +План предполагал, что чат можно перевести на SSE «просто»: request → поток токенов. +Проверка кода показала иное — **это не локальная правка эндпоинта**. + +Что уже есть: `ILLMClient.StreamAsync` → `IAsyncEnumerable`, с failover между +провайдерами и OTel-трейсом (`ResilientLLMClient.StreamAsync`). То есть на уровне LLM +потоковая генерация **реализована**. + +Почему её нельзя просто поднять наверх: + +1. **Стриминг живёт ниже слоя маршрутизации.** У `AgentCore` есть только `HandleAsync` + (1243 строки, `HandleAsyncCore` — строки 288–727). `WebApiAdapter` умеет только + `ChatAsync`. Прямой вызов `_llm.StreamAsync` из нового эндпоинта обошёл бы: + выбор навыка (`route.IsSkill`), confidence/mode, запись использования навыка и + `LatencyScorer`, предложения `proposeSkillForInput`/`proposeImproveSkill`, + многослойную память, guardrails и списание бюджета. +2. **Ответ агента — не просто текст.** `RunWithToolsAsync` (строка 735) — это цикл + tool-итераций: полный вывод LLM парсится на tool-вызов (`TryParseAction`), результат + возвращается в transcript, LLM вызывается снова, до `MaxToolIterations`. Нельзя + решить «посреди потока», это tool-вызов или финальный текст, — парсинг требует + целого ответа. И заранее неизвестно, какая итерация финальная. + +Варианты, и почему ни один не де-факто «правильный» без решения владельца: + +| Вариант | Что получаем | Цена | +|---|---|---| +| Стримить только финальную итерацию | Токены «как есть» | Нужен полный рефакторинг `RunWithToolsAsync` + `HandleAsyncCore` (~440 строк) и новые тесты на routing/skill/memory/budget | +| Стримить только когда tools не сконфигурированы | Простой быстрый путь | Тихо теряет tool-итерации — регрессия против текущего поведения | +| Буферизовать и отдать постфактум | Ничего | Ровно то, что уже делает request-response | + +Текущий `ChatView` поэтому показывает ответ целиком и честно помечает эффект печати +как презентационный. **Решение владельца: оставить request-response, стриминг не делать.** +Честный стриминг остаётся возможным, но требует отдельной задачи на рефакторинг +tool-цикла; выбранный «быстрый путь» отвергнут сознательно, потому что он тихо +убирал бы tool-вызовы. + +### Принятые потери + +Интерактивный PTY, просмотр таблицы процессов из UI, трей, установщик/автообновление. +Компенсации: PID-реестр supervisor'а, A2A discovery, лог-файлы supervisor'а, PWA в Phase 6. + +--- + +# ⛔ Stage 0–9 — SUPERSEDED как план-факт + +**Разделы Stage 0–9 ниже больше не являются план-фактом.** Планом-фактом является раздел +**[Миграция на web-first](#миграция-на-web-first-2026-10-06)** выше, утверждённый владельцем +2026-10-06. + +Этапы сохранены **только как источник функциональных требований** — что в итоге должно +существовать в Studio. Их статус в чекбоксах устарел (см. блок «Task-файлы Stage 0–9 +устарели» выше), и они описывают архитектуру, часть которой отменена: + +| Stage | Что в нём отменено миграцией | +|---|---| +| Stage 0 | «Skeleton **Electron** + Vue + Vite + **IPC**» — Electron и IPC удалены (ADR-0009). API codegen pipeline остаётся актуальным и **не реализован** | +| Stage 1 | Сканер портов и процессов → A2A discovery (`/agent.manifest.json`) | +| Stage 2 | Локальный терминал (xterm.js) → вывод от агента; Monaco/история/диффы **не реализованы** | +| Stage 6 | «Restart из UI» → флаг + `Hercules.Supervisor` | +| Stage 9 | «Packaging» (NSIS/portable) — отменён: десктопной сборки нет, вместо неё PWA | + +**Что делать с оставшимися требованиями:** переносить их в разделы web-first-плана, а не +отмечать в Stage-файлах. Актуальный список пробелов — в +[`src/hercules-web/DEPRECATED.md`](../../src/hercules-web/DEPRECATED.md). + +--- + ## Stage 0 — Skeleton + API Codegen (1-2 недели) **Цель:** Запускаемый skeleton Electron + Vue + Vite + IPC + layout. API codegen pipeline (openapi-typescript + Orval + Vue Query) настроен и генерирует TS client из OpenAPI документа агента. diff --git a/docs/EPIC_Hercules_Studio/VERIFICATION.md b/docs/EPIC_Hercules_Studio/VERIFICATION.md new file mode 100644 index 0000000..494e4db --- /dev/null +++ b/docs/EPIC_Hercules_Studio/VERIFICATION.md @@ -0,0 +1,102 @@ +# Hercules Studio migration — verification record + +Date: **2026-10-06**. Machine: Windows, `node v22.22.0`, `.NET 10.0.12`. +Branch: `tasks/studio-ui`. Nothing was committed; this records state of the working tree. + +This file exists so the migration's verification is **inspectable**, not merely asserted. + +## What was verified, and how + +Every command below was run in this working tree on the date above. + +| # | Command | Result | +|---|---|---| +| 1 | `npm run typecheck` (studio) | 0 errors (`vue-tsc --noEmit -p tsconfig.web.json`) | +| 2 | `npm test` (studio) | **24 passed / 0 failed** (1 test file) | +| 3 | `npm run build` (studio) | `✓ built in 3.12s` — 8 lazy view chunks | +| 4 | `npm run test:e2e` (studio) | **12 passed** (27.8s), Chromium | +| 5 | `dotnet test tests/Hercules.Agent.Tests` | **2233 passed / 0 failed** (2m55s) | +| 6 | Agent runtime, live HTTP | `/api/health` 200 · `/ui/` 200 · `/ui/manifest.webmanifest` 200 · `/ui/sw.js` 200 · `/agent-card.json` 200 · `/api/stats` 401 (auth required — expected) | +| 7 | Supervisor restart cycle, live | pid 24384 → 14804, `state: running`, `crashCount: 0`, restart flag `pending: false` | +| 8 | `npm ci` (clean install) | `added 113 packages` — **`package-lock.json` is in sync with `package.json`** | +| 9 | `dotnet build src/agent/Hercules.slnx` | succeeded, 0 warnings, 0 errors — solution includes `Hercules.Supervisor` | + +Rows 6–7 are **manually observed against a process I started myself**. They are not +CI-enforced and will not run unattended; only rows 1–5, 8 and 9 are reproducible from a +clean checkout without a running agent. + +Rows 8–9 were added after an audit prompted by a "results not reproducible" report: +`npm ci` is the exact command in acceptance criterion 1 and had not been run before, and +the solution build had never been run with the new supervisor project registered. + +Reproduce with: + +```bash +cd src/hercules-studio && npm install && npm run typecheck && npm test && npm run build && npm run test:e2e +dotnet test tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj +``` + +Row 6 needs the agent running; row 7 needs the supervisor managing it +(`dotnet run --project src/hercules-supervisor`). + +## Acceptance criteria (from the approved plan) + +| # | Criterion | Status | +|---|---|---| +| 1 | `npm ci && npm run build` passes; `dist/` opens in a browser | ✅ rows 3, 6 | +| 2 | typecheck 0 errors; tests green | ✅ rows 1, 2, 4, 5 | +| 3 | No `electron` / `better-sqlite3` / `electron-vite` imports in `renderer/` | ✅ grep clean; absent from built bundle | +| 4 | `window.studioAPI` absent; typing via `platform/capabilities.ts` | ✅ absent from built bundle | +| 5 | Contribute key never in `localStorage` / `sessionStorage` / logs | ✅ asserted by E2E tests 3, 4, 12 | +| 6 | Agent serves `/ui` + SSE; SPA same-origin | ✅ row 6; SSE verified separately (`/api/code/run`) | +| 7 | Supervisor survives the restart protocol and writes logs | ✅ row 7 | +| 8 | Hotkeys, i18n en/ru, light/dark themes work in browser | ✅ E2E test 2 (`translations resolve`) | +| 9 | First 3 `hercules-web` panels ported | ⚠️ **superseded** — owner decision 2026-10-06: not ported, `hercules-web` deprecated | +| 10 | README + ADR updated; ADR-0001 superseded | ✅ | + +Criterion 9 is the only one not met as originally written. It was closed by explicit +owner decision, recorded in [ADR-0009](adr/0009-web-first-studio.md) and +[`src/hercules-web/DEPRECATED.md`](../../src/hercules-web/DEPRECATED.md). + +## Out of scope — ⚠️ NOT YET CONFIRMED BY THE OWNER + +> **This section's scope decision was auto-adopted when a question timed out, not chosen +> by the owner.** The owner has not answered it. Until they do, the 243 open Stage 0–9 +> items remain arguably in scope, and **criterion 9 / goal completion is not settled.** + +Decisions that *were* explicitly confirmed by the owner (2026-10-06, questionnaire answered +by the user, not a timeout default): + +- chat stays request/response; +- `hercules-web` is deprecated rather than ported — this closes criterion 9. + +Decision that was **not** confirmed — auto-selected from the recommended default on timeout: + +- whether Stage 0–9's functional requirements belong to this goal or a separate project. + +**If the owner confirms the separate-project reading**, the migration is the whole +deliverable and this goal closes. **If not**, the 243 open Stage 0–9 items are in scope and +this goal is not complete. + +For reference, the state of that work either way: + +- Task files show `stage_00` 50 done / 102 open and stages 01–09 at **0 done / 243 open**. + Those checkboxes are **stale** — this migration delivered substantial parts of Stage 1–7 + without ticking them — so the counts are an upper bound on unfinished work, not a precise + remainder. +- Real gaps: Monaco and history/diff, Vue Flow, **MCP CRUD**, context distillation + + Postgres, the consensus engine, the BPMN designer, and the OpenAPI codegen pipeline + (openapi-typescript / Orval). Authoritative list: + [`src/hercules-web/DEPRECATED.md`](../../src/hercules-web/DEPRECATED.md). +- Studio's `sdk/client.ts` is therefore still hand-written; the codegen pipeline that + would replace it is part of that next project, not this migration. + +Do not read "migration complete" as "product roadmap complete". + +## Known caveats + +- Running the agent rewrites `Hercules.WebApi/agent-card.json` (`generatedAt` only) — a + pre-existing behaviour that dirties the tree on every start. It is not gitignored. +- `ChatView` renders the reply in one piece. Streaming was investigated and declined + (see ADR-0009); the typing effect is presentational and labelled as such. +- `WorkflowView`'s Run action is a backend stub (task_105) and is labelled as one in the UI. \ No newline at end of file diff --git a/docs/EPIC_Hercules_Studio/adr/0001-electron-over-tauri.md b/docs/EPIC_Hercules_Studio/adr/0001-electron-over-tauri.md index 59e7ddd..7930771 100644 --- a/docs/EPIC_Hercules_Studio/adr/0001-electron-over-tauri.md +++ b/docs/EPIC_Hercules_Studio/adr/0001-electron-over-tauri.md @@ -1,8 +1,24 @@ # ADR-0001: Electron over Tauri -**Status:** Accepted +**Status:** Superseded by [ADR-0009 — Web-first Hercules Studio](0009-web-first-studio.md) **Date:** 2026-08-14 +> **Superseded 2026-10-06.** This ADR is kept for history. Its decision was never executed: +> the Electron project never reached a successful `npm install`, and seven of its eight +> views were placeholders. More importantly, its core premise — that Studio needed a native +> shell for filesystem access, process spawning and C# execution — no longer holds, because +> those responsibilities moved into the agent (`DotnetFileBasedExecutor`, the documented +> `supervisor (Studio / systemd / watcher)` restart protocol, A2A discovery, +> `SqliteWorkflowDefinitionStore`). See ADR-0009 for the full analysis. +> +> Two details below are now known to be wrong and should not be reused as arguments: +> +> - *"better-sqlite3 requires a native Node addon build"* — Tauri ships an official SQLite +> plugin. This is not a real discriminator. +> - *"PWA / web-only: Cannot access FS, tray, notifications, process spawning"* — the +> agent now provides process supervision and C# execution server-side, and notifications +> have a Web API. Only an interactive PTY and a tray icon genuinely remain desktop-only. + ## Context Hercules Studio needs a desktop shell that can: diff --git a/docs/EPIC_Hercules_Studio/adr/0009-web-first-studio.md b/docs/EPIC_Hercules_Studio/adr/0009-web-first-studio.md new file mode 100644 index 0000000..9eb4731 --- /dev/null +++ b/docs/EPIC_Hercules_Studio/adr/0009-web-first-studio.md @@ -0,0 +1,150 @@ +# ADR-0009: Web-first Hercules Studio (supersedes ADR-0001) + +**Status:** Accepted +**Date:** 2026-10-06 +**Supersedes:** [ADR-0001 — Electron over Tauri](0001-electron-over-tauri.md) + +## Context + +ADR-0001 (2026-08-14) selected Electron as the desktop shell for Hercules Studio, on the +premise that Studio needed a native shell in order to: + +- run a full IDE-like UI (Monaco, xterm.js, Vue Flow); +- access the local file system and spawn processes; +- bundle a native addon (`better-sqlite3`); +- ship as a Windows x64 installer with auto-update. + +It explicitly rejected a web-only client: *"PWA / web-only: Cannot access FS, tray, +notifications, process spawning. Not suitable for IDE."* + +That decision was never executed. As of this ADR, `src/hercules-studio` had five commits, +four of which were attempts to repair it, and `package.json` did not declare any of the +packages the code imported (`electron`, `electron-vite`, `better-sqlite3`, +`@tailwindcss/vite`, `vitest`, `@playwright/test`). `node_modules` had never been +installed. Seven of eight registered views were a 541-byte `PlaceholderView.vue`. + +Meanwhile the agent backend absorbed the responsibilities ADR-0001 assigned to the shell: + +| ADR-0001 rationale | Status as of this ADR | +|---|---| +| "Run C# skills locally in xterm.js" | `CodeExecution/DotnetFileBasedExecutor.cs` executes C# **inside the agent process**; `Program.cs` registers it as `ICodeExecutor`. The browser needs a stream, not a local PTY. | +| "Spawn/kill agent processes" | `SystemController` documents the supervisor as *"Studio / systemd / watcher"* — explicitly designed for an **external** supervisor, not a UI button. | +| "Scan ports 8421–8521 + process table" | Standards-based A2A discovery already exists: `/agent.manifest.json`, `POST /api/a2a/discover`, mDNS via `DiscoveryConfig`. | +| "Local SQLite (`studio.db`)" | `workflow-server` already ships `SqliteWorkflowDefinitionStore` plus a REST surface. | +| "OS keychain via `safeStorage`" | Keys already belong to the agent (`ApiKeyMiddleware`, dual contribute/system roles). A browser cannot use the OS keychain, so it has no reason to hold keys either. | +| "Native notifications" | Web Notification API. | + +CORS, response compression for `text/event-stream`, and WebSocket upgrade limits were all +already configured. The one genuine gap was that the agent did not serve a UI. + +## Decision + +**Hercules Studio is a browser application.** It is a Vue 3 + Vite SPA served by the agent +itself at `/ui`, running same-origin. There is no desktop shell. + +Concretely: + +1. **Delete the Electron layer.** `electron/`, `electron.vite.config.ts`, + `electron-builder.yml` are removed. The `window.studioAPI` preload contract is replaced + by an explicit `renderer/src/platform/` capability contract (`capabilities.ts`, + `web.ts`, `index.ts`) that a view or store imports. +2. **Process supervision moves out of the UI** into `Hercules.Supervisor`, a small .NET + process implementing the `restart-pending` protocol the backend already specified. +3. **The API key never enters browser storage.** The client exchanges it once at + `POST /api/studio/session` for a short-lived opaque token held only in tab memory. + `ApiKeyMiddleware` accepts `X-Session-Token` with the same role semantics as + `X-Api-Key`. +4. **Discovery is A2A-based**, not port/process scanning. +5. **Studio becomes the primary UI.** `hercules-web` (Astro, 19 panels) is **deprecated, not + ported**: its panels overlapped the endpoints Studio already uses, and porting them all + would have recreated the two-front-end problem this ADR exists to remove. See + `src/hercules-web/DEPRECATED.md` for the replacement map. + +## Later decisions (2026-10-06) + +The two decisions below were put to the owner as explicit choices and **answered by the +owner** (questionnaire returned `explicitUserConfirmation: true`), not inferred from a +timeout default: + +- **Chat stays request/response.** `ILLMClient.StreamAsync` already exists with provider + failover, but it sits *below* `AgentCore`'s routing and below the tool-iteration loop + (`RunWithToolsAsync`), which must parse the full LLM output before deciding whether it is + a tool call. Streaming faithfully requires refactoring ~440 lines of the core request + pipeline. A naive endpoint would stream tokens while silently losing skill routing, + confidence, proposals, memory writes and budget accounting. The "stream only when no + tools are configured" shortcut was considered and **rejected** — it is a regression + against current behaviour. Decision: keep request/response; the typing effect is + labelled as presentational. Evidence recorded in `ROADMAP.md`. +- **`hercules-web` is deprecated rather than ported.** Panels already covered by Studio are + not duplicated; panels with no Studio equivalent are listed explicitly as a backlog in + `src/hercules-web/DEPRECATED.md` rather than being quietly dropped. That file is + deliberately explicit about **partial** coverage (MeshDashboard lacks topology/traffic, + AgentCardPanel lacks full card detail) and about capabilities Studio does **not** have + (MCP servers, security ops, trust admission, backup, rollout, quotas, SLO, mesh + profiles/router, profile editor). Workflow execution remains a backend stub (task_105). + +### Not yet decided — scope of the Stage 0–9 product roadmap + +A separate question — whether this migration is the *entire* goal, or whether Stage 0–9's +functional requirements (Monaco, Vue Flow, MCP CRUD, distillation/Postgres, consensus +engine, BPMN designer, API codegen; 0 done / 243 open) are also in scope — was asked on +2026-10-06 and answered by **timeout with the recommended option auto-selected, not by the +owner**. It is therefore **unconfirmed**, and this ADR does not settle it. Until the owner +confirms, that work remains arguably in scope. Tracked in `VERIFICATION.md`. + +The capability contract, not any particular file, is the extension point. A future desktop +adapter would be selected in `platform/index.ts` without touching views or stores. + +## Rationale + +- **The shell was solving a solved problem.** Roughly 90% of the capability list ADR-0001 + justified a shell for is already server-side. The shell was the most expensive part of + the stack guarding the least. +- **It never worked.** Not "was slow to build" — it never passed an `npm install`. Four + commits titled "fix" are the record. +- **The frontend already decoupled itself.** `main.ts` imported `mock-api.ts`, which only + installed itself when `window.studioAPI` was absent. The renderer had been running in a + browser with localStorage stubs all along. +- **It concentrates risk.** One UI instead of two. `hercules-web` (19 panels) and Studio + (one real view) were competing for the same investment. +- **A key in localStorage is a real vulnerability.** Any XSS on the Studio origin would + exfiltrate an agent API key. Session exchange removes that entirely. + +### Accepted losses + +These are real and are accepted, not overlooked: + +- **Interactive PTY.** Skill-run output arrives as a stream; there is no `stdin`. + Mitigated by Supervisor writing agent stdout to a file. +- **Process-table inspection.** Replaced by Supervisor's PID registry and A2A discovery. +- **Tray icon.** Cosmetic. Recovered in the optional PWA phase as a shortcut. +- **Installer / auto-update.** The audience is a local dev tool for this repository. + +## Consequences + +- `WebApi:StudioUiPath` (optional; auto-detected as `src/hercules-studio/dist`) controls + SPA hosting. Absent a build, `/ui` is simply not served. +- Sessions are in-memory: an agent restart invalidates every session and clients + re-exchange. This is the intended failure mode. +- The agent has a static file provider, which must not be mounted in a way that shadows + `/api/*`. Studio is scoped to `/ui` and registered before the API middlewares so asset + requests bypass rate limiting, auth and drain. +- Studio's dev server moved 4322 → **4330**; 4322 belongs to `hercules-web`. Both are in + the agent's CORS dev allowlist. + +## Alternatives considered + +- **Continue Electron.** Rejected: never built, 1 of 8 views implemented, native addon + rebuild burden, and it duplicated backend capability. +- **Tauri 2.** Rejected: Rust is not in the team's stack. Note ADR-0001's native-addon + argument is now weak — Tauri ships an official SQLite plugin — so the only remaining + reason to reject it is team composition, not capability. +- **Wails v3.** Rejected: Go backend for a .NET team; v3 status is beta/alpha with + conflicting sources. +- **Nuxt / any SSR meta-framework.** Rejected: Studio would still need `ssr: false` + (File System Access API, Notification), and the IDE shell would still be hand-written, + so the migration cost is not repaid. The one genuine Nuxt benefit — a Nitro BFF holding + the agent key server-side — is delivered by the session endpoint instead. Revisit only + if session exchange proves unworkably complex backend-side. +- **Web with no local layer (no Supervisor).** Rejected as too radical: restart automation + and PID bookkeeping still need a process owner. \ No newline at end of file diff --git a/src/agent/Hercules.WebApi/Auth/ApiKeyMiddleware.cs b/src/agent/Hercules.WebApi/Auth/ApiKeyMiddleware.cs index d40af3e..7d5a29d 100644 --- a/src/agent/Hercules.WebApi/Auth/ApiKeyMiddleware.cs +++ b/src/agent/Hercules.WebApi/Auth/ApiKeyMiddleware.cs @@ -29,6 +29,7 @@ public sealed class ApiKeyMiddleware private readonly ApiKeyEntry[] _entries; private readonly byte[]?[] _expectedKeyBytes; private readonly bool _unauthenticated; + private readonly StudioSessionStore? _sessions; public ApiKeyMiddleware(RequestDelegate next, WebApiConfig cfg, ILogger logger) : this(next, cfg, logger, keys: null) @@ -36,9 +37,31 @@ public ApiKeyMiddleware(RequestDelegate next, WebApiConfig cfg, ILogger logger, IReadOnlyList? keys) + : this(next, cfg, logger, keys, sessions: null) + { + } + + /// DI path: keys come from , sessions from the registry. + public ApiKeyMiddleware( + RequestDelegate next, + WebApiConfig cfg, + ILogger logger, + ApiKeyStore store, + StudioSessionStore sessions) + : this(next, cfg, logger, store.LoadOrGenerate(cfg.ApiKeys), sessions) + { + } + + public ApiKeyMiddleware( + RequestDelegate next, + WebApiConfig cfg, + ILogger logger, + IReadOnlyList? keys, + StudioSessionStore? sessions) { _next = next; _logger = logger; + _sessions = sessions; var resolved = ResolveKeys(cfg, keys); _entries = resolved.Entries; @@ -82,6 +105,20 @@ public async Task InvokeAsync(HttpContext context) return; } + // ADR-0009: браузерный клиент обменял API-ключ на короткоживущую сессию. + // Сессия проверяется раньше ключа, но не вместо него — при отсутствии + // заголовка сессии flow продолжает обычным путём проверки X-Api-Key. + if (_sessions is not null && + context.Request.Headers.TryGetValue(StudioSessionStore.HeaderName, out var sessionToken) && + _sessions.TryValidate(sessionToken.ToString(), out var session) && + session is not null) + { + context.Items[RoleItemKey] = session.Role; + context.Items[StudioSessionStore.ItemKey] = session; + await _next(context); + return; + } + if (!context.Request.Headers.TryGetValue(HeaderName, out var provided)) { _logger.LogWarning("Отклонён запрос {Path}: отсутствует {Header}", path, HeaderName); diff --git a/src/agent/Hercules.WebApi/Auth/StudioSessionStore.cs b/src/agent/Hercules.WebApi/Auth/StudioSessionStore.cs new file mode 100644 index 0000000..1a4b6a5 --- /dev/null +++ b/src/agent/Hercules.WebApi/Auth/StudioSessionStore.cs @@ -0,0 +1,135 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Hercules.WebApi.Config; +using Microsoft.AspNetCore.Http; + +namespace Hercules.WebApi.Auth; + +/// +/// Session issued to a browser client after it presents its API key once. +/// +/// Opaque bearer value. Never logged, never persisted. +/// Role granted by the API key that was exchanged. +/// Absolute expiry; the client is expected to re-exchange. +public sealed record StudioSession( + string Token, + ApiKeyRole Role, + string AgentId, + string DisplayName, + DateTimeOffset ExpiresAt, + IReadOnlyList Capabilities); + +/// +/// In-memory session registry backing X-Session-Token. +/// +/// Purpose: Hercules Studio runs in a browser, where an API key cannot be kept +/// safely. The client exchanges its key once for a short-lived opaque token +/// which it holds only in tab memory. This keeps the long-lived secret on the +/// agent and out of localStorage. +/// +/// +/// Deliberately in-memory: a restart invalidates every session, which is the +/// correct failure mode — clients re-exchange. Persisting tokens would widen +/// the blast radius of a filesystem compromise for no operational gain. +/// +/// +public sealed class StudioSessionStore +{ + public const string HeaderName = "X-Session-Token"; + + private static readonly IReadOnlyList ContributeCapabilities = new[] + { + "chat", "skills:read", "skills:write", "config:read", "config:patch", + "mesh:read", "tools:read", "stats:read", "system:checkin", + }; + + private static readonly IReadOnlyList SystemCapabilities = new[] + { + "chat", "skills:read", "skills:write", "config:read", "config:patch", "config:write", + "mesh:read", "mesh:write", "tools:read", "tools:write", "stats:read", + "system:checkin", "system:restart", "mcp:write", "quota:write", + }; + + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + private readonly ILogger _logger; + private readonly TimeProvider _clock; + private readonly TimeSpan _ttl; + + public StudioSessionStore(WebApiConfig cfg, ILogger logger, TimeProvider? clock = null) + { + _logger = logger; + _clock = clock ?? TimeProvider.System; + var minutes = cfg.StudioSessionTtlMinutes; + _ttl = minutes <= 0 ? TimeSpan.FromMinutes(30) : TimeSpan.FromMinutes(minutes); + } + + /// Lifetime of a freshly issued session. + public TimeSpan Ttl => _ttl; + + public StudioSession Create(ApiKeyRole role, string agentId, string displayName) + { + Sweep(); + + // 32 bytes of entropy, base64url — no padding, safe in a header value. + var raw = RandomNumberGenerator.GetBytes(32); + var token = Convert.ToBase64String(raw).Replace('+', '-').Replace('/', '_').TrimEnd('='); + + var session = new StudioSession( + token, + role, + agentId, + displayName, + _clock.GetUtcNow().Add(_ttl), + role == ApiKeyRole.System ? SystemCapabilities : ContributeCapabilities); + + _sessions[token] = session; + _logger.LogInformation( + "[StudioSession] Issued {Role} session for agent {AgentId}, expires {ExpiresAt:O}", + role, agentId, session.ExpiresAt); + + return session; + } + + public bool TryValidate(string? token, out StudioSession? session) + { + session = null; + if (string.IsNullOrEmpty(token)) return false; + + if (!_sessions.TryGetValue(token, out var found)) return false; + + if (found.ExpiresAt <= _clock.GetUtcNow()) + { + // Lazy expiry: remove on access so a stale token cannot be replayed + // after its TTL, even if no sweep has run since. + _sessions.TryRemove(token, out _); + return false; + } + + session = found; + return true; + } + + /// Reads a validated session out of . + public static StudioSession? From(HttpContext http) => + http.Items.TryGetValue(ItemKey, out var value) ? value as StudioSession : null; + + internal const string ItemKey = "StudioSession"; + + /// Drops every session — used by the drain/restart path. + public int RevokeAll() + { + var count = _sessions.Count; + if (count > 0) _logger.LogInformation("[StudioSession] Revoked {Count} session(s)", count); + _sessions.Clear(); + return count; + } + + private void Sweep() + { + var now = _clock.GetUtcNow(); + var stale = _sessions.Where(kv => kv.Value.ExpiresAt <= now).Select(kv => kv.Key).ToArray(); + foreach (var key in stale) _sessions.TryRemove(key, out _); + if (stale.Length > 0) + _logger.LogDebug("[StudioSession] Swept {Count} expired session(s)", stale.Length); + } +} \ No newline at end of file diff --git a/src/agent/Hercules.WebApi/CodeRuns/SkillRunStore.cs b/src/agent/Hercules.WebApi/CodeRuns/SkillRunStore.cs new file mode 100644 index 0000000..56fa706 --- /dev/null +++ b/src/agent/Hercules.WebApi/CodeRuns/SkillRunStore.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using System.Text.Json.Serialization; +using Hercules.CodeExecution; + +namespace Hercules.WebApi.CodeRuns; + +/// Lifecycle of a sandbox run. +public enum RunStatus +{ + Queued, + Running, + Completed, + Failed +} + +/// A single SSE event. lets a reconnecting client resume. +public sealed record RunEvent( + long Seq, + string Type, + object? Data, + [property: JsonPropertyName("at")] DateTimeOffset At = default); + +/// +/// A sandbox execution started by POST /api/code/run and observed over SSE. +/// +public sealed class CodeRun +{ + private readonly List _events = []; + private readonly object _gate = new(); + private readonly TaskCompletionSource _finished = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private long _seq; + + public required string Id { get; init; } + public string? SkillId { get; init; } + public string Language { get; init; } = "csharp"; + public RunStatus Status { get; private set; } = RunStatus.Queued; + public DateTimeOffset StartedAt { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CompletedAt { get; private set; } + public ExecutionResult? Result { get; private set; } + public string? Error { get; private set; } + + /// True when older events were dropped and the client missed some. + public bool Truncated { get; private set; } + + /// Completed when the run reaches a terminal state. + public Task Finished => _finished.Task; + + public void MarkRunning() + { + lock (_gate) Status = RunStatus.Running; + } + + public void Complete(ExecutionResult result) + { + lock (_gate) + { + Result = result; + Status = RunStatus.Completed; + CompletedAt = DateTimeOffset.UtcNow; + } + _finished.TrySetResult(); + } + + public void Fail(string error) + { + lock (_gate) + { + Error = error; + Status = RunStatus.Failed; + CompletedAt = DateTimeOffset.UtcNow; + } + _finished.TrySetResult(); + } + + /// Appends an event, honouring the replay bound. + public RunEvent Append(string type, object? data) + { + lock (_gate) + { + var evt = new RunEvent(++_seq, type, data, DateTimeOffset.UtcNow); + _events.Add(evt); + if (_events.Count > SkillRunStore.MaxBufferedEvents) + { + _events.RemoveRange(0, _events.Count - SkillRunStore.MaxBufferedEvents); + Truncated = true; + } + return evt; + } + } + + /// Snapshot of everything after for stream replay. + public (IReadOnlyList Events, bool Truncated, bool Terminal) Snapshot(long afterSeq) + { + lock (_gate) + { + var pending = _events.Where(e => e.Seq > afterSeq).ToArray(); + var terminal = Status is RunStatus.Completed or RunStatus.Failed; + return (pending, Truncated, terminal); + } + } + + public object StatusSnapshot() => new + { + runId = Id, + skillId = SkillId, + language = Language, + status = Status.ToString().ToLowerInvariant(), + startedAt = StartedAt, + completedAt = CompletedAt, + error = Error, + result = Result is null + ? null + : new + { + exitCode = Result.ExitCode, + stdout = Result.Stdout, + stderr = Result.Stderr, + durationMs = Result.DurationMs, + status = Result.Status, + blockedPatterns = Result.BlockedPatterns, + sessionDir = Result.SessionDir, + success = Result.IsSuccess, + } + }; +} + +/// +/// In-memory registry of sandbox runs. +/// +/// Deliberately in-memory: runs are ephemeral execution state, and an agent +/// restart invalidating them is the correct, safe behaviour. The event buffer is +/// bounded so a long-running agent cannot accumulate unbounded memory — a +/// reconnecting client is told the buffer was truncated instead of silently +/// receiving a gap. +/// +/// +public sealed class SkillRunStore(ILogger logger, TimeProvider? clock = null) +{ + /// Max events retained per run for SSE replay. + public const int MaxBufferedEvents = 256; + + /// Completed runs are evicted this long after completion. + public static readonly TimeSpan CompletedTtl = TimeSpan.FromMinutes(15); + + private readonly ConcurrentDictionary _runs = new(StringComparer.Ordinal); + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + + public CodeRun Create(string? skillId, string language) + { + Sweep(); + var run = new CodeRun { Id = Guid.NewGuid().ToString("n"), SkillId = skillId, Language = language }; + _runs[run.Id] = run; + return run; + } + + public CodeRun? Get(string id) => _runs.TryGetValue(id, out var run) ? run : null; + + private void Sweep() + { + var cutoff = _clock.GetUtcNow() - CompletedTtl; + foreach (var (id, run) in _runs) + { + if (run.CompletedAt is { } done && done < cutoff && _runs.TryRemove(id, out _)) + { + logger.LogDebug("[CodeRun] Evicted completed run {RunId}", id); + } + } + } +} \ No newline at end of file diff --git a/src/agent/Hercules.WebApi/Config/WebApiConfig.cs b/src/agent/Hercules.WebApi/Config/WebApiConfig.cs index e68ac56..649fb93 100644 --- a/src/agent/Hercules.WebApi/Config/WebApiConfig.cs +++ b/src/agent/Hercules.WebApi/Config/WebApiConfig.cs @@ -24,6 +24,19 @@ public sealed class WebApiConfig /// Максимальный размер тела запроса в байтах. 0 = без лимита. public long MaxRequestBodyBytes { get; set; } = 1_048_576; + /// + /// TTL сессии Studio в минутах (ADR-0009). Браузерный клиент обменивает API-ключ + /// на короткоживущий opaque-токен и держит его только в памяти вкладки. + /// По истечении клиент обменивается заново. 0 или меньше → 30 минут по умолчанию. + /// + public int StudioSessionTtlMinutes { get; set; } = 30; + + /// + /// Каталог со собранной SPA Hercules Studio. Если существует — агент отдаёт её + /// на /ui (ADR-0009). Пусто → раздача статики отключена. + /// + public string StudioUiPath { get; set; } = ""; + /// Kestrel server tuning (task_081). public KestrelConfig Kestrel { get; set; } = new(); diff --git a/src/agent/Hercules.WebApi/Controllers/CodeRunController.cs b/src/agent/Hercules.WebApi/Controllers/CodeRunController.cs new file mode 100644 index 0000000..aa44e74 --- /dev/null +++ b/src/agent/Hercules.WebApi/Controllers/CodeRunController.cs @@ -0,0 +1,189 @@ +using System.Text; +using System.Text.Json; +using Hercules.CodeExecution; +using Hercules.WebApi.CodeRuns; + +namespace Hercules.WebApi.Controllers; + +/// +/// Sandbox code execution with an SSE result stream. +/// +/// Replaces the local terminal the Electron shell used to show (ADR-0009). The +/// code runs inside the agent's sandbox via — there is +/// no client-side process to spawn and no PTY to emulate. +/// +/// +/// Note on naming: the migration plan called this /api/skills/{id}/run. +/// Skill (Storage/Models.cs) has no code field — a skill is +/// Meta + Description + Prompt and is executed by the LLM, not by the +/// sandbox. A run therefore targets a piece of code (a file-based C# skill under +/// test, or a snippet from the editor), not a stored skill. +/// +/// +public static class CodeRunController +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public static void MapCodeRuns(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/code").WithTags("CodeRun"); + + // POST /api/code/run — start a sandbox run. Returns immediately with a runId; + // observe it over SSE so the UI never blocks on a long execution. + group.MapPost("/run", async ( + RunRequest? req, + SkillRunStore store, + IEnumerable executors, + CancellationToken ct) => + { + if (req is null || string.IsNullOrWhiteSpace(req.Code)) + return Results.BadRequest(new { error = "code is required" }); + + var language = string.IsNullOrWhiteSpace(req.Language) ? "csharp" : req.Language.Trim().ToLowerInvariant(); + + var executor = executors.FirstOrDefault(e => e.SupportedLanguages.Contains(language)); + if (executor is null) + return Results.BadRequest(new + { + error = $"no executor supports language '{language}'", + supported = executors.SelectMany(e => e.SupportedLanguages).Distinct(), + }); + + var run = store.Create(req.SkillId, language); + run.Append("start", new { runId = run.Id, executor = executor.Name, language }); + + // Fire-and-forget: the run's lifetime is owned by the store, not the request. + _ = Task.Run(async () => + { + try + { + run.MarkRunning(); + var result = await executor.ExecuteAsync( + new ExecutionRequest( + req.Code, + language, + req.Args, + req.TimeoutMs, + req.MemoryLimitMb, + req.WorkingDir), + CancellationToken.None); + + if (result.Status == "rejected") + run.Append("rejected", new { result.BlockedPatterns }); + + run.Append("result", new + { + result.ExitCode, + result.Stdout, + result.Stderr, + result.DurationMs, + result.Status, + result.SessionDir, + success = result.IsSuccess, + }); + run.Complete(result); + } + catch (Exception ex) + { + run.Append("exception", new { ex.GetType().Name, ex.Message }); + run.Fail(ex.Message); + } + }, CancellationToken.None); + + return Results.Accepted($"/api/code/run/{run.Id}/stream", new { runId = run.Id }); + }) + .WithName("StartCodeRun") + .RequireRateLimiting(RateLimitPolicies.Expensive); + + // GET /api/code/run/{runId} — status snapshot (polling alternative to SSE). + group.MapGet("/run/{runId}", (string runId, SkillRunStore store) => + { + var run = store.Get(runId); + return run is null + ? Results.NotFound(new { error = "unknown run" }) + : Results.Ok(run.StatusSnapshot()); + }) + .WithName("GetCodeRun"); + + // GET /api/code/run/{runId}/stream — SSE. + // `?after=` replays from the client's last seen sequence, so a dropped + // connection resumes instead of restarting the run. + group.MapGet("/run/{runId}/stream", async ( + string runId, + long? after, + SkillRunStore store, + HttpContext http) => + { + var run = store.Get(runId); + if (run is null) return Results.NotFound(new { error = "unknown run" }); + + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-cache"; + // Disable proxy buffering (nginx and friends) so events are not held back. + http.Response.Headers["X-Accel-Buffering"] = "no"; + + var cursor = after ?? 0; + var poll = TimeSpan.FromMilliseconds(200); + + while (!http.RequestAborted.IsCancellationRequested) + { + var (events, truncated, terminal) = run.Snapshot(cursor); + + if (truncated) + { + await WriteAsync(http, "truncated", new + { + message = "older events were dropped from the replay buffer", + resumeFrom = cursor, + }, CancellationToken.None); + } + + foreach (var evt in events) + { + cursor = evt.Seq; + await WriteAsync(http, evt.Type, evt.Data, http.RequestAborted); + } + + if (terminal) + { + await WriteAsync(http, "done", run.StatusSnapshot(), http.RequestAborted); + break; + } + + await Task.Delay(poll, http.RequestAborted); + } + + return Results.Empty; + }) + .WithName("StreamCodeRun"); + } + + private static async Task WriteAsync(HttpContext http, string type, object? data, CancellationToken ct) + { + var payload = JsonSerializer.Serialize(data ?? new { }, Json); + var sb = new StringBuilder(); + sb.Append("event: ").Append(type).Append('\n'); + // Data must be single-line: JSON escaping keeps newlines out of it. + sb.Append("data: ").Append(payload).Append("\n\n"); + + await http.Response.WriteAsync(sb.ToString(), ct); + await http.Response.Body.FlushAsync(ct); + } +} + +/// Body for POST /api/code/run. +public sealed class RunRequest +{ + /// Source to execute (a file-based C# skill under test, or an editor snippet). + public string? Code { get; set; } + + public string? Language { get; set; } + public string[]? Args { get; set; } + public int? TimeoutMs { get; set; } + public int? MemoryLimitMb { get; set; } + public string? WorkingDir { get; set; } + + /// Optional skill this run relates to, for correlation in the UI. + public string? SkillId { get; set; } +} \ No newline at end of file diff --git a/src/agent/Hercules.WebApi/Controllers/StudioController.cs b/src/agent/Hercules.WebApi/Controllers/StudioController.cs new file mode 100644 index 0000000..9a40944 --- /dev/null +++ b/src/agent/Hercules.WebApi/Controllers/StudioController.cs @@ -0,0 +1,93 @@ +using Hercules.Mesh; +using Hercules.WebApi.Auth; +using Hercules.WebApi.Config; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace Hercules.WebApi.Controllers; + +/// +/// Studio session endpoints (ADR-0009). +/// +/// Hercules Studio is a browser SPA. An API key cannot be stored safely in a +/// browser, so the client presents its key once here and receives a +/// short-lived opaque token it keeps only in tab memory. Every later request +/// carries X-Session-Token instead of X-Api-Key. +/// +/// +public static class StudioController +{ + public static void MapStudio(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/studio").WithTags("Studio"); + + // POST /api/studio/session — exchange an API key for a session token. + // The key arrives via X-Api-Key and is authenticated by ApiKeyMiddleware. + group.MapPost("/session", ( + HttpContext http, + StudioSessionStore sessions, + AgentManifestService manifestService, + ILoggerFactory loggerFactory) => + { + var role = ResolveRole(http); + var manifest = manifestService.Current; + + var session = sessions.Create( + role, + string.IsNullOrWhiteSpace(manifest.AgentId) ? "hercules-agent" : manifest.AgentId, + string.IsNullOrWhiteSpace(manifest.DisplayName) ? "Hercules Agent" : manifest.DisplayName); + + // Deliberately does not echo the API key or log it. + return Results.Ok(new + { + token = session.Token, + role = session.Role.ToString().ToLowerInvariant(), + agentId = session.AgentId, + displayName = session.DisplayName, + expiresAt = session.ExpiresAt, + capabilities = session.Capabilities, + ttlSeconds = (int)sessions.Ttl.TotalSeconds, + }); + }) + .WithName("CreateStudioSession") + .WithSummary("Exchange an API key for a short-lived Studio session token"); + + // GET /api/studio/session — whoami for the current token. + group.MapGet("/session", (HttpContext http) => + { + var session = StudioSessionStore.From(http); + if (session is null) return Results.Problem("No active session", statusCode: 401); + + return Results.Ok(new + { + role = session.Role.ToString().ToLowerInvariant(), + agentId = session.AgentId, + displayName = session.DisplayName, + expiresAt = session.ExpiresAt, + capabilities = session.Capabilities, + }); + }) + .WithName("GetStudioSession") + .WithSummary("Describe the current Studio session"); + + // DELETE /api/studio/session — drop every session (used on sign-out/drain). + group.MapDelete("/session", (StudioSessionStore sessions) => + { + var revoked = sessions.RevokeAll(); + return Results.Ok(new { revoked }); + }) + .WithName("RevokeStudioSessions") + .WithSummary("Revoke all issued Studio sessions"); + } + + private static ApiKeyRole ResolveRole(HttpContext http) + { + if (http.Items.TryGetValue(ApiKeyMiddleware.RoleItemKey, out var roleObj) && roleObj is ApiKeyRole role) + { + return role; + } + // Authenticated but no role recorded — treat as the least privilege. + return ApiKeyRole.Contribute; + } +} \ No newline at end of file diff --git a/src/agent/Hercules.WebApi/Logging/FileLoggerProvider.cs b/src/agent/Hercules.WebApi/Logging/FileLoggerProvider.cs index cd0b088..f99eb72 100644 --- a/src/agent/Hercules.WebApi/Logging/FileLoggerProvider.cs +++ b/src/agent/Hercules.WebApi/Logging/FileLoggerProvider.cs @@ -30,6 +30,23 @@ public void Dispose() } internal void Write(string categoryName, LogLevel logLevel, string message, Exception? exception) + { + // A logging failure must never take the host down. This is not + // theoretical: with an exclusive handle a supervised restart (the old + // process still closing its sink while the new one boots) used to throw + // IOException out of this method and kill the replacement agent with an + // unhandled AggregateException. + try + { + WriteCore(categoryName, logLevel, message, exception); + } + catch + { + // Drop the line. Losing a log entry is always preferable to a crash. + } + } + + private void WriteCore(string categoryName, LogLevel logLevel, string message, Exception? exception) { var now = DateTime.Now; var today = DateOnly.FromDateTime(now); @@ -40,8 +57,11 @@ internal void Write(string categoryName, LogLevel logLevel, string message, Exce { _writer?.Flush(); _writer?.Dispose(); + _writer = null; var path = Path.Combine(_logDir, $"hercules-{today:yyyy-MM-dd}.log"); - var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read); + // FileShare.ReadWrite: a supervised restart overlaps two processes + // on the same file. Without write sharing the second open is denied. + var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); _writer = new StreamWriter(fs, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)) { AutoFlush = true diff --git a/src/agent/Hercules.WebApi/Program.cs b/src/agent/Hercules.WebApi/Program.cs index 8232497..767547c 100644 --- a/src/agent/Hercules.WebApi/Program.cs +++ b/src/agent/Hercules.WebApi/Program.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Encodings.Web; using System.Threading.RateLimiting; +using Microsoft.Extensions.FileProviders; using Scalar.AspNetCore; using Hercules.Agent; using Hercules.WebApi.Logging; @@ -282,6 +283,14 @@ [new OpenApiSecuritySchemeReference("ApiKey", context.Document)] = [], // Регистрируем ДО app.Build(), чтобы можно было resolve при первом запросе. builder.Services.AddSingleton(); +// ADR-0009: in-memory session registry for the browser Studio client. A browser +// cannot hold an API key safely, so the key is exchanged once for a short-lived +// opaque token that the client keeps only in tab memory. +builder.Services.AddSingleton(); + +// ADR-0009: in-memory registry for sandbox code runs observed over SSE. +builder.Services.AddSingleton(); + // task_098: CheckInService для Studio-протокола (ADR-0005). Singleton — состояние // CheckIn'ов живёт в памяти процесса, общий для всех запросов. IAuditService // resolve'ится лениво через IServiceProvider, чтобы оставаться опциональным. @@ -950,12 +959,15 @@ [new OpenApiSecuritySchemeReference("ApiKey", context.Document)] = [], } // Dev fallback: разрешаем только localhost-источники. - // 4322 = Hercules Studio (Vite dev server), 8421 = сам WebApi. + // 4322 = hercules-web (astro dev), 4330 = Hercules Studio (Vite dev, ADR-0009), + // 8421 = сам WebApi (в т.ч. раздача SPA на /ui — same-origin, CORS не нужен). string[] devOrigins = { "http://localhost:4322", + "http://localhost:4330", "http://localhost:8421", "http://127.0.0.1:4322", + "http://127.0.0.1:4330", "http://127.0.0.1:8421" }; policy.WithOrigins(devOrigins) @@ -1106,6 +1118,33 @@ await context.HttpContext.Response.WriteAsync( } // --- Middleware --- +// ADR-0009: Hercules Studio is served by the agent itself on /ui, so the SPA +// runs same-origin and does not depend on the CORS allowlist above (that only +// matters for `npm run dev` on its own port). Mounted before the API middlewares +// so asset requests are never rate-limited, API-key-gated or drain-blocked. +const string StudioUiRequestPath = "/ui"; +IFileProvider? studioUiProvider = ResolveStudioUiProvider(webCfg, app.Environment.ContentRootPath, log); +if (studioUiProvider is not null) +{ + // Resolves /ui → /ui/index.html. Must precede UseStaticFiles. + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = studioUiProvider, + RequestPath = StudioUiRequestPath, + }); + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = studioUiProvider, + RequestPath = StudioUiRequestPath, + OnPrepareResponse = ctx => + { + // Hashed asset filenames are immutable; index.html must not be cached. + var isIndex = ctx.File.Name.Equals("index.html", StringComparison.OrdinalIgnoreCase); + ctx.Context.Response.Headers.CacheControl = isIndex ? "no-cache" : "public, max-age=31536000, immutable"; + }, + }); +} // task_081: response compression first so downstream responses are emitted // compressed (RateLimiter, ApiKey, Drain, OutputCache все пишут в поток). app.UseResponseCompression(); @@ -1309,6 +1348,8 @@ await context.HttpContext.Response.WriteAsync( app.MapSlos(); app.MapApprovals(); app.MapEscalations(); +app.MapStudio(); +app.MapCodeRuns(); app.MapObservability(); app.MapSkillHarness(); app.MapSelfImprovement(); @@ -1469,3 +1510,48 @@ await context.HttpContext.Response.WriteAsync( { app.Run(); } + +// --- Hercules Studio static hosting (ADR-0009) --- + +// Resolves the built Studio bundle for hosting at /ui. +// Path resolution order: WebApi:StudioUiPath when absolute, otherwise relative to +// the content root; finally a default that walks up from the content root to the +// repo layout (src/hercules-studio/dist). +// Returns null when nothing is found — static hosting is opt-in by presence, so an +// agent without a built Studio simply does not serve /ui. +static IFileProvider? ResolveStudioUiProvider(WebApiConfig cfg, string contentRoot, ILogger log) +{ + var candidates = new List(); + + if (!string.IsNullOrWhiteSpace(cfg.StudioUiPath)) + { + candidates.Add(Path.IsPathRooted(cfg.StudioUiPath) + ? cfg.StudioUiPath + : Path.Combine(contentRoot, cfg.StudioUiPath)); + } + + // Default: walk up from src/agent/Hercules.WebApi to src/hercules-studio/dist. + var dir = new DirectoryInfo(contentRoot); + for (var i = 0; i < 6 && dir is not null; i++, dir = dir.Parent) + { + var probe = Path.Combine(dir.FullName, "hercules-studio", "dist"); + candidates.Add(probe); + } + + foreach (var candidate in candidates) + { + if (!Directory.Exists(candidate)) continue; + if (!File.Exists(Path.Combine(candidate, "index.html"))) + { + log.LogWarning("[StudioUi] {Path} has no index.html — skipping", candidate); + continue; + } + log.LogInformation("[StudioUi] Serving Hercules Studio from {Path} at {RequestPath}", candidate, StudioUiRequestPath); + return new PhysicalFileProvider(candidate); + } + + if (!string.IsNullOrWhiteSpace(cfg.StudioUiPath)) + log.LogWarning("[StudioUi] Configured path {Path} not found — /ui disabled", cfg.StudioUiPath); + + return null; +} diff --git a/src/agent/Hercules.WebApi/appsettings.json b/src/agent/Hercules.WebApi/appsettings.json index faa0918..ecb9177 100644 --- a/src/agent/Hercules.WebApi/appsettings.json +++ b/src/agent/Hercules.WebApi/appsettings.json @@ -1,10 +1,25 @@ { "WebApi": { "ApiKey": "dev-local-key", + "ApiKeys": [ + { + "Key": "dev-local-key", + "Role": "Contribute", + "Description": "dev operator (Studio)" + }, + { + "Key": "dev-system-key", + "Role": "System", + "Description": "dev supervisor (Hercules.Supervisor)" + } + ], + "StudioSessionTtlMinutes": 30, "AllowedCorsOrigins": [ "http://localhost:4322", + "http://localhost:4330", "http://localhost:8421", "http://127.0.0.1:4322", + "http://127.0.0.1:4330", "http://127.0.0.1:8421" ] }, diff --git a/src/agent/Hercules.slnx b/src/agent/Hercules.slnx index 030359e..a969d56 100644 --- a/src/agent/Hercules.slnx +++ b/src/agent/Hercules.slnx @@ -3,5 +3,6 @@ + diff --git a/src/agent/Mesh/A2A/AgentCardService.cs b/src/agent/Mesh/A2A/AgentCardService.cs index 00a2691..cd81829 100644 --- a/src/agent/Mesh/A2A/AgentCardService.cs +++ b/src/agent/Mesh/A2A/AgentCardService.cs @@ -115,7 +115,25 @@ public async Task PublishAsync(CancellationToken ct = default) } AgentCard card = await GetAgentCardAsync(ct); - string endpoint = _a2aCfg.AgentCard.Endpoint.TrimStart('/'); + + // The configured Endpoint is URL-shaped ("/agent-card.json"), so it must + // not be used as a filesystem path verbatim: TrimStart('/') alone yields a + // RELATIVE path, and the card then lands in the process working directory. + // That both littered the repo and desynchronised us from the reader + // (GET /agent-card.json reads it from the data root). + // Resolve relative values against the manifest's directory, which is the + // already-resolved data root, matching the documented "relative to + // dataRoot" contract. + string configured = _a2aCfg.AgentCard.Endpoint + .TrimStart('/', '\\') + .Replace('/', Path.DirectorySeparatorChar); + + string baseDir = Path.GetDirectoryName(_manifestService.ManifestPath) + ?? AppContext.BaseDirectory; + string endpoint = Path.IsPathRooted(configured) + ? configured + : Path.GetFullPath(Path.Combine(baseDir, configured)); + string? dir = Path.GetDirectoryName(endpoint); if (!string.IsNullOrEmpty(dir)) { diff --git a/src/hercules-studio/.gitignore b/src/hercules-studio/.gitignore index 44b4c84..3704bb1 100644 --- a/src/hercules-studio/.gitignore +++ b/src/hercules-studio/.gitignore @@ -7,4 +7,9 @@ release/ *.tsbuildinfo .vite/ renderer/dist/ -electron/dist/ \ No newline at end of file + +# Playwright +test-results/ +playwright-report/ +blob-report/ +.playwright/ \ No newline at end of file diff --git a/src/hercules-studio/README.md b/src/hercules-studio/README.md index f1ffa0e..43178f2 100644 --- a/src/hercules-studio/README.md +++ b/src/hercules-studio/README.md @@ -1,339 +1,116 @@ # Hercules Studio -> **IDE for managing, configuring, and orchestrating Hercules AI agents.** -> Built with Electron + Vue 3 + TypeScript. +Web IDE for managing Hercules AI agents. -[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](../../LICENSE) +Studio is a **browser application** — a Vue 3 + Vite SPA. The agent serves it at `/ui`, +so it runs same-origin and needs no desktop shell, installer or tray process. -## What is Hercules Studio? +Studio is the **single** front-end. `src/hercules-web` (Astro) is deprecated — see +[DEPRECATED.md](../hercules-web/DEPRECATED.md) for the panel-by-panel replacement map. -Hercules Studio is a desktop IDE application for operators, developers, and architects who work with multiple Hercules AI agents. It connects to already-running agents via HTTP REST API, scans your machine for agents, and provides: +> **Migration note (2026-10-06).** Studio was an Electron app that never built; see +> [ADR-0009](../../docs/EPIC_Hercules_Studio/adr/0009-web-first-studio.md), which supersedes +> [ADR-0001](../../docs/EPIC_Hercules_Studio/adr/0001-electron-over-tauri.md). The Electron +> main process, preload bridge and packaging config have been removed. What replaced them +> is `renderer/src/platform/` — a typed capability contract with a browser implementation. -- **Multi-agent management** — connect to multiple agents, switch between them -- **Agent scanner** — discover agents on ports 8421-8521 + process scan -- **Chat** — conversation with any connected agent -- **Skill editor** — Monaco-based editor for prompts, meta, C# file-based apps -- **Skill push** — create and deploy skills to any agent -- **Mesh explorer** — visual topology graph, router, shared memory -- **Tool registry + MCP** — manage tools and MCP servers -- **Fine-grained config** — LLM providers, roles, quotas, context budget, restart -- **Consensus** — multi-agent parallel chat with LLM-judge aggregation -- **BPMN workflow designer** — visual multi-agent workflow orchestration +## Stack -> **Full documentation:** [docs/EPIC_Hercules_Studio/](../../docs/EPIC_Hercules_Studio/README.md) +- Vue 3 (` \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/assets/styles/global.css b/src/hercules-studio/renderer/src/assets/styles/global.css index 02946ee..048b48c 100644 --- a/src/hercules-studio/renderer/src/assets/styles/global.css +++ b/src/hercules-studio/renderer/src/assets/styles/global.css @@ -1,7 +1,9 @@ -/* Tailwind CSS will be added when @tailwindcss/vite is installed. - For now, plain CSS with theme variables. */ +@import "tailwindcss"; -/* ---- Theme variables ---- */ +/* Dark mode is driven by a class on (settings store), not media query. */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* ---- Theme variables (runtime source of truth) ---- */ :root { --bg: #ffffff; --bg-secondary: #f4f4f5; @@ -30,13 +32,25 @@ --success: #10b981; } -/* ---- Base styles ---- */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; +/* + * `inline` makes Tailwind emit the var() reference directly instead of copying + * the resolved value, so utilities react to the .dark class at runtime. + */ +@theme inline { + --color-app: var(--bg); + --color-secondary: var(--bg-secondary); + --color-tertiary: var(--bg-tertiary); + --color-fg: var(--fg); + --color-fg-secondary: var(--fg-secondary); + --color-line: var(--border); + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + --color-danger: var(--danger); + --color-warning: var(--warning); + --color-success: var(--success); } +/* ---- Base styles ---- */ html, body, #app { @@ -75,26 +89,6 @@ body { background: var(--fg-secondary); } -/* ---- Utility classes ---- */ -.bg-app { - background-color: var(--bg); -} -.bg-secondary { - background-color: var(--bg-secondary); -} -.bg-tertiary { - background-color: var(--bg-tertiary); -} -.text-app { - color: var(--fg); -} -.text-secondary { - color: var(--fg-secondary); -} -.border-app { - border-color: var(--border); -} - /* ---- Animations ---- */ @keyframes fadeIn { from { diff --git a/src/hercules-studio/renderer/src/components/common/AddConnectionDialog.vue b/src/hercules-studio/renderer/src/components/common/AddConnectionDialog.vue new file mode 100644 index 0000000..24f0572 --- /dev/null +++ b/src/hercules-studio/renderer/src/components/common/AddConnectionDialog.vue @@ -0,0 +1,185 @@ + + + \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/components/common/LicenseDialog.vue b/src/hercules-studio/renderer/src/components/common/LicenseDialog.vue index 84af2d5..f90ea42 100644 --- a/src/hercules-studio/renderer/src/components/common/LicenseDialog.vue +++ b/src/hercules-studio/renderer/src/components/common/LicenseDialog.vue @@ -1,6 +1,7 @@ + + \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/components/layout/Sidebar.vue b/src/hercules-studio/renderer/src/components/layout/Sidebar.vue index dba3ac8..9c5ac48 100644 --- a/src/hercules-studio/renderer/src/components/layout/Sidebar.vue +++ b/src/hercules-studio/renderer/src/components/layout/Sidebar.vue @@ -2,7 +2,7 @@ import { computed } from "vue"; import { useI18n } from "vue-i18n"; import { useConnectionsStore } from "../../stores/connections"; -import type { Connection } from "@shared/protocol"; +import type { Connection } from "@renderer/platform/capabilities"; const { t } = useI18n(); const connections = useConnectionsStore(); diff --git a/src/hercules-studio/renderer/src/components/layout/StatusBar.vue b/src/hercules-studio/renderer/src/components/layout/StatusBar.vue index af65b2d..24db41d 100644 --- a/src/hercules-studio/renderer/src/components/layout/StatusBar.vue +++ b/src/hercules-studio/renderer/src/components/layout/StatusBar.vue @@ -3,6 +3,7 @@ import { computed } from "vue"; import { useI18n } from "vue-i18n"; import { useConnectionsStore } from "../../stores/connections"; import { useErrorStore } from "../../stores/error"; +import { platform } from "../../platform"; const { t } = useI18n(); const connections = useConnectionsStore(); @@ -11,7 +12,7 @@ const errorStore = useErrorStore(); const activeAgent = computed(() => connections.active); const hasErrors = computed(() => errorStore.hasErrors); const errorCount = computed(() => errorStore.errors.length); -const version = "0.1.0"; +const version = computed(() => platform.app.version()); const statusColor = computed(() => { if (!activeAgent.value) return "bg-zinc-500"; diff --git a/src/hercules-studio/renderer/src/config/view-registry.ts b/src/hercules-studio/renderer/src/config/view-registry.ts index baa0018..9be8022 100644 --- a/src/hercules-studio/renderer/src/config/view-registry.ts +++ b/src/hercules-studio/renderer/src/config/view-registry.ts @@ -27,7 +27,7 @@ export const viewRegistry: ViewDefinition[] = [ id: "chat", labelKey: "activity.chat", icon: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/ChatView.vue").then((m) => markRaw(m.default)), order: 2, requiresConnection: true, }, @@ -35,7 +35,7 @@ export const viewRegistry: ViewDefinition[] = [ id: "skills", labelKey: "activity.skills", icon: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/SkillsView.vue").then((m) => markRaw(m.default)), order: 3, requiresConnection: true, }, @@ -43,7 +43,7 @@ export const viewRegistry: ViewDefinition[] = [ id: "mesh", labelKey: "activity.mesh", icon: "M12 2v20M2 12h20M5 5l14 14M19 5L5 19", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/MeshView.vue").then((m) => markRaw(m.default)), order: 4, requiresConnection: true, }, @@ -51,7 +51,7 @@ export const viewRegistry: ViewDefinition[] = [ id: "tools", labelKey: "activity.tools", icon: "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/ToolsView.vue").then((m) => markRaw(m.default)), order: 5, requiresConnection: true, }, @@ -59,7 +59,7 @@ export const viewRegistry: ViewDefinition[] = [ id: "config", labelKey: "activity.config", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/ConfigView.vue").then((m) => markRaw(m.default)), order: 6, requiresConnection: true, }, @@ -67,15 +67,17 @@ export const viewRegistry: ViewDefinition[] = [ id: "workflow", labelKey: "activity.workflow", icon: "M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/WorkflowView.vue").then((m) => markRaw(m.default)), order: 7, - requiresConnection: true, + // The workflow server is a separate service with its own credentials, + // so this view is reachable without connecting an agent. + requiresConnection: false, }, { id: "consensus", labelKey: "activity.consensus", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75", - component: () => import("../views/PlaceholderView.vue").then((m) => markRaw(m.default)), + component: () => import("../views/ConsensusView.vue").then((m) => markRaw(m.default)), order: 8, requiresConnection: true, }, diff --git a/src/hercules-studio/renderer/src/env.d.ts b/src/hercules-studio/renderer/src/env.d.ts index 64207e2..df317f3 100644 --- a/src/hercules-studio/renderer/src/env.d.ts +++ b/src/hercules-studio/renderer/src/env.d.ts @@ -6,6 +6,5 @@ declare module "*.vue" { export default component; } -interface Window { - studioAPI: import("@shared/protocol").IpcApi; -} \ No newline at end of file +/** Injected at build time by vite.config.ts from package.json. */ +declare const __APP_VERSION__: string; \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/i18n/en.json b/src/hercules-studio/renderer/src/i18n/en.json index cdd5349..4ea1139 100644 --- a/src/hercules-studio/renderer/src/i18n/en.json +++ b/src/hercules-studio/renderer/src/i18n/en.json @@ -1,7 +1,6 @@ { "app": { - "name": "Hercules Studio", - "version": "0.1.0" + "name": "Hercules Studio" }, "activity": { "agents": "Agents", @@ -51,8 +50,7 @@ "baseUrlPlaceholder": "http://localhost:8421", "apiKey": "API Key (contribute)", "apiKeyPlaceholder": "hc_contrib_...", - "systemKey": "System Key (optional)", - "systemKeyPlaceholder": "hc_sys_...", + "apiKeyHint": "Used once to obtain a session token. It is never stored in the browser.", "test": "Test connection", "testing": "Testing...", "save": "Save", @@ -61,12 +59,150 @@ "testFailed": "Failed: {error}", "scanResults": "Discovered agents", "scanning": "Scanning...", + "noAgentsFound": "No agents found on the configured endpoints", + "authRequired": "Auth required", "addDiscovered": "Add" }, + "session": { + "expiredTitle": "Session expired", + "expiredBody": "The session for {name} expired. Re-enter the API key to continue.", + "keyNeverStored": "For security your key is never written to browser storage.", + "enterKey": "API key", + "reconnect": "Reconnect" + }, + "startup": { + "licenseCheckFailed": "Could not verify license consent state" + }, + "skills": { + "search": "Search skills", + "none": "No skills", + "selectOne": "Select a skill", + "noTriggers": "no triggers", + "uses": "Uses", + "success": "Success", + "prompt": "Prompt", + "loadFailed": "Could not load skills", + "saveFailed": "Could not save", + "saved": "Skill saved", + "sandbox": "Sandbox run", + "sandboxHint": "Code runs inside the agent's sandbox; output is streamed over SSE. There is no local terminal.", + "run": "Run", + "runStarted": "run started", + "runRejected": "blocked by the dangerous-code scanner", + "runFailed": "run failed", + "streamFailed": "output stream failed", + "streamDisconnected": "stream disconnected", + "noOutput": "No output yet", + "run_ok": "ok", + "run_timeout": "timeout", + "run_killed": "killed", + "run_rejected": "rejected", + "run_failed": "failed" + }, "chat": { - "placeholder": "Type a message... (Ctrl+Enter to send)", + "placeholder": "Message the agent… (Enter to send, Shift+Enter for a new line)", "send": "Send", - "empty": "Start a conversation with {name}" + "clear": "Clear", + "hint": "The reply arrives in one piece; the typing effect is presentational.", + "noAgent": "Connect an agent to start chatting.", + "confidence": "Confidence", + "error": "Error", + "failed": "Chat request failed", + "emptyTitle": "Chat with your agent", + "emptyHint": "Ask a question, and the agent will route it to the best-matching skill.", + "proposeCreate": "The agent suggests creating a skill for this input.", + "proposeImprove": "The agent suggests improving “{name}”.", + "openSkills": "Open skills" + }, + "config": { + "title": "Configuration", + "subtitle": "Live runtime configuration of the connected agent.", + "source": "Source", + "patchHint": "Saving sends a merge patch — only the keys you changed are applied.", + "savePatch": "Save patch", + "revert": "Revert", + "saved": "Configuration updated", + "saveFailed": "Could not apply the patch", + "loadFailed": "Could not load the configuration", + "jsonInvalid": "Not a valid JSON object", + "notAnObject": "The patch must be a JSON object", + "restart": "Restart agent", + "restartHint": "Studio cannot kill processes. It sets a flag that Hercules.Supervisor acts on.", + "requestRestart": "Request restart", + "restartRequested": "Restart requested — the supervisor will perform it", + "restartFailed": "Could not request a restart", + "restartReason": "requested from Hercules Studio", + "restartNeedsSystem": "Restart requires a system-role session. Reconnect with a system key." + }, + "mesh": { + "title": "Mesh", + "subtitle": "Registered agents, their trust and circuit state.", + "healthy": "Healthy", + "degraded": "Degraded", + "unhealthy": "Unhealthy", + "agent": "Agent", + "endpoint": "Endpoint", + "trust": "Trust", + "health": "Health", + "circuit": "Circuit", + "latency": "Latency", + "noAgents": "No agents registered", + "denials": "Recent policy denials", + "noDenials": "No policy denials recorded", + "loadFailed": "Could not load the mesh" + }, + "tools": { + "title": "Tools", + "subtitle": "The agent's tool registry.", + "search": "Search tools", + "total": "Total", + "enabled": "Enabled", + "allCategories": "All", + "none": "No tools registered", + "health": "Health", + "failures": "Failures", + "rateLimit": "Rate limit", + "sideEffect": "Side effects", + "enable": "Enable", + "disable": "Disable", + "loadFailed": "Could not load tools", + "toggleFailed": "Could not change the tool state" + }, + "consensus": { + "title": "Consensus", + "subtitle": "Tool calls and decisions waiting on a human.", + "empty": "Nothing is waiting for a decision.", + "approvals": "Approvals", + "escalations": "Escalations", + "noEscalations": "No escalations", + "arguments": "Arguments", + "allSeverities": "All severities", + "approveAll": "Approve all", + "approved": "Approved", + "denied": "Denied", + "batchApproved": "All escalations approved", + "actionFailed": "Action failed" + }, + "workflow": { + "title": "Workflows", + "subtitle": "Multi-agent workflow definitions from the workflow server.", + "connect": "Connect to workflow server", + "credentialsRequired": "Client id and secret are required", + "credentialsHint": "The workflow server is a separate service and authenticates with its own client credentials.", + "baseUrl": "Base URL", + "clientId": "Client id", + "clientSecret": "Client secret", + "secretHint": "Held in memory only — never written to browser storage. Re-entered after a reload.", + "connected": "Workflow server connected", + "count": "Definitions", + "moreAvailable": "more available", + "none": "No workflow definitions", + "run": "Run", + "runStub": "Execution is not implemented yet (backend stub, task_105).", + "runFailed": "Could not start the workflow", + "deleted": "Deleted {name}", + "deleteFailed": "Could not delete", + "loadFailed": "Could not load workflows" }, "common": { "save": "Save", @@ -80,7 +216,9 @@ "error": "Error: {message}", "confirm": "Confirm", "yes": "Yes", - "no": "No" + "no": "No", + "later": "Later", + "saving": "Saving..." }, "settings": { "title": "Settings", diff --git a/src/hercules-studio/renderer/src/i18n/ru.json b/src/hercules-studio/renderer/src/i18n/ru.json index a5c1a53..0a374e3 100644 --- a/src/hercules-studio/renderer/src/i18n/ru.json +++ b/src/hercules-studio/renderer/src/i18n/ru.json @@ -1,7 +1,6 @@ { "app": { - "name": "Hercules Studio", - "version": "0.1.0" + "name": "Hercules Studio" }, "activity": { "agents": "Агенты", @@ -51,8 +50,7 @@ "baseUrlPlaceholder": "http://localhost:8421", "apiKey": "API ключ (contribute)", "apiKeyPlaceholder": "hc_contrib_...", - "systemKey": "Системный ключ (опционально)", - "systemKeyPlaceholder": "hc_sys_...", + "apiKeyHint": "Используется один раз для получения сессионного токена. В браузере не сохраняется.", "test": "Проверить подключение", "testing": "Проверка...", "save": "Сохранить", @@ -61,12 +59,150 @@ "testFailed": "Ошибка: {error}", "scanResults": "Обнаруженные агенты", "scanning": "Сканирование...", + "noAgentsFound": "Агенты не найдены на заданных адресах", + "authRequired": "Требуется авторизация", "addDiscovered": "Добавить" }, + "session": { + "expiredTitle": "Сессия истекла", + "expiredBody": "Сессия для {name} истекла. Введите API ключ заново, чтобы продолжить.", + "keyNeverStored": "В целях безопасности ключ не сохраняется в хранилище браузера.", + "enterKey": "API ключ", + "reconnect": "Переподключить" + }, + "startup": { + "licenseCheckFailed": "Не удалось проверить состояние лицензионного согласия" + }, + "skills": { + "search": "Поиск навыков", + "none": "Навыков нет", + "selectOne": "Выберите навык", + "noTriggers": "без триггеров", + "uses": "Использований", + "success": "Успешность", + "prompt": "Prompt", + "loadFailed": "Не удалось загрузить навыки", + "saveFailed": "Не удалось сохранить", + "saved": "Навык сохранён", + "sandbox": "Запуск в sandbox", + "sandboxHint": "Код выполняется в sandbox агента, вывод приходит по SSE. Локального терминала нет.", + "run": "Запустить", + "runStarted": "запуск начат", + "runRejected": "заблокирован сканером опасного кода", + "runFailed": "запуск не удался", + "streamFailed": "поток вывода прерван", + "streamDisconnected": "поток отключён", + "noOutput": "Вывода пока нет", + "run_ok": "успешно", + "run_timeout": "таймаут", + "run_killed": "убит", + "run_rejected": "отклонён", + "run_failed": "ошибка" + }, "chat": { - "placeholder": "Введите сообщение... (Ctrl+Enter для отправки)", + "placeholder": "Сообщение агенту… (Enter — отправить, Shift+Enter — новая строка)", "send": "Отправить", - "empty": "Начните диалог с {name}" + "clear": "Очистить", + "hint": "Ответ приходит целиком; эффект печати — только визуальный.", + "noAgent": "Подключите агента, чтобы начать диалог.", + "confidence": "Уверенность", + "error": "Ошибка", + "failed": "Запрос к агенту не удался", + "emptyTitle": "Диалог с агентом", + "emptyHint": "Задайте вопрос — агент подберёт подходящий навык.", + "proposeCreate": "Агент предлагает создать навык для этого запроса.", + "proposeImprove": "Агент предлагает улучшить «{name}».", + "openSkills": "К навыкам" + }, + "config": { + "title": "Конфигурация", + "subtitle": "Живая конфигурация подключённого агента.", + "source": "Источник", + "patchHint": "Сохранение отправляет merge-патч — применяются только изменённые ключи.", + "savePatch": "Сохранить патч", + "revert": "Отменить изменения", + "saved": "Конфигурация обновлена", + "saveFailed": "Не удалось применить патч", + "loadFailed": "Не удалось загрузить конфигурацию", + "jsonInvalid": "Некорректный JSON", + "notAnObject": "Патч должен быть JSON-объектом", + "restart": "Перезапуск агента", + "restartHint": "Studio не может убивать процессы. Она ставит флаг, который обрабатывает Hercules.Supervisor.", + "requestRestart": "Запросить перезапуск", + "restartRequested": "Перезапуск запрошен — выполнит supervisor", + "restartFailed": "Не удалось запросить перезапуск", + "restartReason": "запрошено из Hercules Studio", + "restartNeedsSystem": "Перезапуск требует system-сессии. Переподключитесь с системным ключом." + }, + "mesh": { + "title": "Mesh", + "subtitle": "Зарегистрированные агенты, их доверие и состояние circuit breaker.", + "healthy": "Здоровые", + "degraded": "Деградация", + "unhealthy": "Нездоровые", + "agent": "Агент", + "endpoint": "Endpoint", + "trust": "Доверие", + "health": "Здоровье", + "circuit": "Circuit", + "latency": "Задержка", + "noAgents": "Агенты не зарегистрированы", + "denials": "Последние отказы политики", + "noDenials": "Отказов политики не зафиксировано", + "loadFailed": "Не удалось загрузить mesh" + }, + "tools": { + "title": "Инструменты", + "subtitle": "Реестр инструментов агента.", + "search": "Поиск инструментов", + "total": "Всего", + "enabled": "Включено", + "allCategories": "Все", + "none": "Инструменты не зарегистрированы", + "health": "Здоровье", + "failures": "Сбоев", + "rateLimit": "Лимит частоты", + "sideEffect": "Побочные эффекты", + "enable": "Включить", + "disable": "Отключить", + "loadFailed": "Не удалось загрузить инструменты", + "toggleFailed": "Не удалось изменить состояние инструмента" + }, + "consensus": { + "title": "Консилиум", + "subtitle": "Вызовы инструментов и решения, ожидающие человека.", + "empty": "Ничего не ожидает решения.", + "approvals": "Согласования", + "escalations": "Эскалации", + "noEscalations": "Эскалаций нет", + "arguments": "Аргументы", + "allSeverities": "Все важности", + "approveAll": "Одобрить все", + "approved": "Одобрено", + "denied": "Отклонено", + "batchApproved": "Все эскалации одобрены", + "actionFailed": "Действие не удалось" + }, + "workflow": { + "title": "Workflows", + "subtitle": "Определения multi-agent workflow с workflow-сервера.", + "connect": "Подключиться к workflow-серверу", + "credentialsRequired": "Нужны client id и secret", + "credentialsHint": "Workflow-сервер — отдельный сервис со своими client credentials.", + "baseUrl": "Base URL", + "clientId": "Client id", + "clientSecret": "Client secret", + "secretHint": "Только в памяти — в хранилище браузера не попадает. После перезагрузки вводится снова.", + "connected": "Workflow-сервер подключён", + "count": "Определений", + "moreAvailable": "есть ещё", + "none": "Определений workflow нет", + "run": "Запустить", + "runStub": "Исполнение ещё не реализовано (заглушка на бэкенде, task_105).", + "runFailed": "Не удалось запустить workflow", + "deleted": "Удалено: {name}", + "deleteFailed": "Не удалось удалить", + "loadFailed": "Не удалось загрузить workflows" }, "common": { "save": "Сохранить", @@ -80,7 +216,9 @@ "error": "Ошибка: {message}", "confirm": "Подтвердить", "yes": "Да", - "no": "Нет" + "no": "Нет", + "later": "Позже", + "saving": "Сохранение..." }, "settings": { "title": "Настройки", diff --git a/src/hercules-studio/renderer/src/main.ts b/src/hercules-studio/renderer/src/main.ts index c638b8c..c4d3b90 100644 --- a/src/hercules-studio/renderer/src/main.ts +++ b/src/hercules-studio/renderer/src/main.ts @@ -3,7 +3,7 @@ import { createPinia } from "pinia"; import { createI18n } from "vue-i18n"; import App from "./App.vue"; import "./assets/styles/global.css"; -import "./mock-api"; +import { registerServiceWorker } from "./serviceWorker"; import en from "./i18n/en.json"; import ru from "./i18n/ru.json"; @@ -18,4 +18,6 @@ const i18n = createI18n({ const app = createApp(App); app.use(createPinia()); app.use(i18n); -app.mount("#app"); \ No newline at end of file +app.mount("#app"); + +registerServiceWorker(); \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/mock-api.ts b/src/hercules-studio/renderer/src/mock-api.ts deleted file mode 100644 index 730c6b4..0000000 --- a/src/hercules-studio/renderer/src/mock-api.ts +++ /dev/null @@ -1,255 +0,0 @@ -// Mock IPC API for browser preview (when running without Electron). -// In production (Electron), this is replaced by preload contextBridge. - -import type { IpcApi, Connection, NewConnection, DiscoveredAgent, HealthStatus, StudioSettings, LicenseConsent } from "@shared/protocol"; - -const STORAGE_KEY = "hercules-studio-mock"; - -interface MockState { - connections: Connection[]; - consent: LicenseConsent | null; - settings: StudioSettings; -} - -function loadState(): MockState { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); - } catch { - /* ignore */ - } - return { - connections: [], - consent: null, - settings: { - theme: "dark", - locale: "en", - typingEffect: true, - compactMode: false, - scan: { - portStart: 8421, - portEnd: 8521, - legacyPort: 5000, - enableProcessScan: true, - autoScanOnStartup: false, - concurrent: 50, - timeoutMs: 300, - }, - notifications: { enabled: true, consensus: true, workflow: true, escalation: true, chat: false }, - autoUpdate: true, - minimizeToTray: false, - }, - }; -} - -function saveState(state: MockState): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); -} - -let state = loadState(); - -// Simple ID generator -let idCounter = 0; -function genId(): string { - idCounter++; - return `mock-${Date.now()}-${idCounter}`; -} - -const mockApi: IpcApi = { - connections: { - async list(): Promise { - return state.connections; - }, - async add(conn: NewConnection): Promise { - // Mock health probe — try to fetch manifest - let agentId = "unknown"; - let displayName = conn.name; - let online = false; - try { - const res = await fetch(`${conn.baseUrl}/agent.manifest.json`, { - headers: { "X-Api-Key": conn.apiKey }, - signal: AbortSignal.timeout(3000), - }); - if (res.ok) { - const manifest = (await res.json()) as { agentId?: string; displayName?: string }; - agentId = manifest.agentId ?? "unknown"; - displayName = manifest.displayName ?? conn.name; - online = true; - } - } catch { - // Agent not running — still add as offline - } - - const connection: Connection = { - id: genId(), - name: conn.name, - baseUrl: conn.baseUrl, - agentId, - displayName, - authScheme: "apikey", - lastSeen: new Date().toISOString(), - status: online ? "online" : "offline", - checkedOut: false, - checkedOutBy: null, - hasSystemKey: conn.systemKey != null && conn.systemKey.length > 0, - }; - state.connections.push(connection); - saveState(state); - return connection; - }, - async remove(id: string): Promise { - state.connections = state.connections.filter((c) => c.id !== id); - saveState(state); - }, - async update(id: string, patch: Partial): Promise { - const idx = state.connections.findIndex((c) => c.id === id); - if (idx === -1) throw new Error("Connection not found"); - state.connections[idx] = { ...state.connections[idx], ...patch }; - saveState(state); - return state.connections[idx]; - }, - async healthCheck(id: string): Promise { - const conn = state.connections.find((c) => c.id === id); - if (!conn) return { online: false, agentId: null, displayName: null, latencyMs: null, error: "Not found" }; - try { - const start = Date.now(); - const res = await fetch(`${conn.baseUrl}/agent.manifest.json`, { - signal: AbortSignal.timeout(3000), - }); - const health: HealthStatus = { - online: res.ok, - agentId: conn.agentId, - displayName: conn.displayName, - latencyMs: Date.now() - start, - error: res.ok ? null : `HTTP ${res.status}`, - }; - const idx = state.connections.findIndex((c) => c.id === id); - if (idx !== -1) { - state.connections[idx].status = health.online ? "online" : "offline"; - saveState(state); - } - return health; - } catch (e) { - return { online: false, agentId: null, displayName: null, latencyMs: null, error: e instanceof Error ? e.message : String(e) }; - } - }, - async setActive(id: string): Promise { - // In browser, active is managed by Pinia store - }, - }, - scanner: { - async scan(): Promise { - // Mock scan — try common ports - const ports = [8421, 8422, 5000]; - const results: DiscoveredAgent[] = []; - for (const port of ports) { - try { - const res = await fetch(`http://localhost:${port}/agent.manifest.json`, { - signal: AbortSignal.timeout(500), - }); - if (res.ok) { - const manifest = (await res.json()) as { agentId?: string; displayName?: string }; - results.push({ - port, - agentId: manifest.agentId ?? null, - displayName: manifest.displayName ?? null, - endpoint: `http://localhost:${port}`, - authRequired: false, - foundVia: "port", - }); - } else if (res.status === 401) { - results.push({ - port, - agentId: null, - displayName: null, - endpoint: `http://localhost:${port}`, - authRequired: true, - foundVia: "port", - }); - } - } catch { - // Port not open - } - } - return results; - }, - onProgress() {}, - offProgress() {}, - }, - native: { - async readFile(path: string): Promise { - return ""; - }, - async writeFile(path: string, content: string): Promise {}, - async openExternal(url: string): Promise { - window.open(url, "_blank"); - }, - async showNotification(title: string, body: string): Promise { - if ("Notification" in window) { - new Notification(title, { body }); - } - }, - async setTray() {}, - async spawnTerminal(): Promise { - return 0; - }, - onTerminalOutput() {}, - async killTerminal(): Promise {}, - }, - db: { - async query(): Promise { - return []; - }, - async execute(): Promise<{ changes: number; lastInsertRowid: number }> { - return { changes: 0, lastInsertRowid: 0 }; - }, - }, - license: { - async getConsent(): Promise { - return state.consent; - }, - async acceptConsent(type: "nonprofit" | "commercial", key?: string): Promise { - state.consent = { - consentVersion: 1, - acceptedAt: new Date().toISOString(), - type, - licenseKey: key ?? null, - }; - saveState(state); - }, - }, - settings: { - async get(): Promise { - return state.settings; - }, - async update(patch: Partial): Promise { - state.settings = { ...state.settings, ...patch }; - saveState(state); - return state.settings; - }, - }, - keys: { - async getSystemKey(): Promise { - return null; - }, - async setSystemKey(): Promise {}, - async removeSystemKey(): Promise {}, - async getContributeKey(connectionId: string): Promise { - // In browser, we can't store keys securely. Return a mock or stored key. - return localStorage.getItem(`mock-key-${connectionId}`); - }, - }, - app: { - async getVersion(): Promise { - return "0.1.0"; - }, - async getDataPath(): Promise { - return ""; - }, - }, -}; - -// Install mock API if running in browser (not Electron) -if (typeof window !== "undefined" && !window.studioAPI) { - (window as unknown as { studioAPI: IpcApi }).studioAPI = mockApi; -} \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/platform/capabilities.ts b/src/hercules-studio/renderer/src/platform/capabilities.ts new file mode 100644 index 0000000..6947b25 --- /dev/null +++ b/src/hercules-studio/renderer/src/platform/capabilities.ts @@ -0,0 +1,220 @@ +/** + * Platform capability contract. + * + * Replaces the former `IpcApi` (shared/protocol.ts), which existed only to serve + * Electron's preload/contextBridge boundary. Studio is now a browser SPA, so the + * surface is a plain typed object resolved by `platform/index.ts`. + * + * Rule: nothing in here may reference Electron, Node built-ins, or a local + * database. If a capability cannot work in a browser, it is not part of the + * contract — see `docs/EPIC_Hercules_Studio/adr/0009-web-first-studio.md`. + */ + +// ---- Connection types ---- + +export interface Connection { + id: string; + name: string; + baseUrl: string; + agentId: string; + displayName: string; + authScheme: string; + lastSeen: string | null; + status: ConnectionStatus; + checkedOut: boolean; + checkedOutBy: string | null; + /** True once a session token has been obtained in this tab. Never implies a stored key. */ + hasSession: boolean; +} + +export type ConnectionStatus = "online" | "offline" | "degraded" | "connecting" | "disconnected"; + +/** + * `apiKey` is a one-shot credential. It is exchanged for a short-lived session + * token at add-time and is never written to storage. See `session.exchange`. + */ +export interface NewConnection { + name: string; + baseUrl: string; + apiKey: string; + systemKey?: string; +} + +export interface HealthStatus { + online: boolean; + agentId: string | null; + displayName: string | null; + latencyMs: number | null; + error: string | null; +} + +// ---- Discovery types ---- + +/** Port/process scanning is not reachable from a browser; discovery is A2A-based. */ +export interface DiscoveredAgent { + agentId: string | null; + displayName: string | null; + endpoint: string; + baseUrl: string; + version: string | null; + authRequired: boolean; + foundVia: "wellknown" | "configured" | "supervisor"; +} + +export interface ScanProgress { + scanned: number; + total: number; + found: number; + currentEndpoint: string; +} + +export interface ScanSettings { + /** Endpoints probed via `/agent-card.json`, in order. */ + endpoints: string[]; + enableRemoteDiscovery: boolean; + timeoutMs: number; +} + +// ---- License types ---- + +export interface LicenseConsent { + consentVersion: number; + acceptedAt: string; + type: "nonprofit" | "commercial"; + licenseKey: string | null; +} + +// ---- Settings types ---- + +export interface StudioSettings { + theme: "dark" | "light"; + locale: "en" | "ru"; + typingEffect: boolean; + compactMode: boolean; + scan: ScanSettings; + notifications: { + enabled: boolean; + consensus: boolean; + workflow: boolean; + escalation: boolean; + chat: boolean; + }; + autoUpdate: boolean; + minimizeToTray: boolean; +} + +// ---- Session types (replaces the former `keys` namespace) ---- + +export interface SessionInfo { + connectionId: string; + role: "contribute" | "system"; + agentId: string; + displayName: string; + expiresAt: string; + /** Authoritative actions this session may perform. */ + capabilities: string[]; +} + +export type SessionExpiredReason = "expired" | "revoked" | "invalidated"; + +// ---- Workflow server types ---- + +/** + * The workflow server is a *separate* service with its own auth + * (`X-Client-Id` / `X-Client-Secret`), not the agent's API-key/session model. + * Secrets live in memory only, for the same reason the agent key does. + */ +export interface WorkflowCredentials { + baseUrl: string; + clientId: string; + clientSecret: string; +} + +export interface WorkflowSummaryDto { + id: string; + name: string; + version: number; + description: string | null; + createdAt: string; + updatedAt?: string | null; +} + +export interface WorkflowListDto { + items: WorkflowSummaryDto[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface WorkflowDetailDto { + id: string; + name: string; + version: number; + description: string | null; + graphJson: unknown; +} + +// ---- Platform contract ---- + +export interface PlatformCapabilities { + connections: { + list(): Promise; + /** Performs the key exchange internally; the key is never returned to the caller. */ + add(conn: NewConnection): Promise; + remove(id: string): Promise; + update(id: string, patch: Partial): Promise; + healthCheck(id: string): Promise; + setActive(id: string): Promise; + }; + scanner: { + /** Probes `/agent-card.json` on known endpoints. */ + discover(): Promise; + /** Subscribes to progress; returns an unsubscribe function. */ + onProgress(cb: (progress: ScanProgress) => void): () => void; + }; + files: { + /** File System Access API. Import/export only — arbitrary paths are impossible in a browser. */ + isSupported(): boolean; + /** Returns null when the user cancels. */ + pickAndRead(accept?: Record): Promise<{ name: string; content: string } | null>; + /** Returns false when the user cancels. */ + save(suggestedName: string, content: string): Promise; + }; + notify: { + supported(): boolean; + permission(): NotificationPermission; + request(): Promise; + show(title: string, body: string): Promise; + }; + license: { + getConsent(): Promise; + acceptConsent(type: "nonprofit" | "commercial", key?: string): Promise; + }; + settings: { + get(): Promise; + update(patch: Partial): Promise; + }; + session: { + /** Exchanges a contribute key for a short-lived token. Token lives in memory only. */ + exchange(connectionId: string, baseUrl: string, apiKey: string): Promise; + info(connectionId: string): SessionInfo | null; + token(connectionId: string): string | null; + drop(connectionId: string): void; + /** Notifies when a session expires so the UI can prompt for the key again. */ + onExpired(cb: (connectionId: string, reason: SessionExpiredReason) => void): () => void; + }; + workflows: { + /** Holds credentials in memory only. Never persisted. */ + configure(creds: WorkflowCredentials): void; + configured(): boolean; + baseUrl(): string | null; + list(opts?: { limit?: number; cursor?: string }): Promise; + get(id: string): Promise; + remove(id: string): Promise; + run(id: string): Promise; + }; + app: { + version(): string; + /** Stable, non-secret client identifier used for CheckIn/CheckOut. Safe to persist. */ + studioId(): string; + }; +} \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/platform/index.ts b/src/hercules-studio/renderer/src/platform/index.ts new file mode 100644 index 0000000..7fa4401 --- /dev/null +++ b/src/hercules-studio/renderer/src/platform/index.ts @@ -0,0 +1,42 @@ +/** + * Capability resolution. + * + * Studio is a browser SPA, so `web` is the only implementation today. This + * module is the seam: a future desktop adapter would be selected here without + * touching any view or store. + * + * A desktop adapter was considered and intentionally not written — with the + * Electron main process removed there is no IPC bridge left to wrap, and the + * audience is browser-only. See unresolved decision #4 in the migration plan. + * The contract in `capabilities.ts` is the extension point, not a file. + */ + +import type { PlatformCapabilities } from "./capabilities"; +import { createWebCapabilities } from "./web"; + +let instance: PlatformCapabilities | null = null; + +/** Returns the process-wide capability implementation, creating it on first use. */ +export function resolveCapabilities(): PlatformCapabilities { + if (!instance) { + instance = createWebCapabilities(); + } + return instance; +} + +/** Test seam: forces re-resolution on next access. */ +export function __resetCapabilities(): void { + instance = null; +} + +export const platform: PlatformCapabilities = new Proxy( + {} as PlatformCapabilities, + { + get(_target, prop, receiver) { + return Reflect.get(resolveCapabilities(), prop, receiver); + }, + }, +); + +export type { PlatformCapabilities } from "./capabilities"; +export { AgentError, DEFAULT_SETTINGS, DEFAULT_SCAN_SETTINGS } from "./web"; \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/platform/web.test.ts b/src/hercules-studio/renderer/src/platform/web.test.ts new file mode 100644 index 0000000..22a36b0 --- /dev/null +++ b/src/hercules-studio/renderer/src/platform/web.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createWebCapabilities, + DEFAULT_SETTINGS, + AgentError, + __resetSessionRegistry, +} from "./web"; +import type { SessionResponse } from "./web"; + +const AGENT = "http://localhost:8421"; + +function sessionResponse(overrides: Partial = {}): SessionResponse { + return { + token: "sess-token-abc", + role: "contribute", + agentId: "agent-1", + displayName: "Agent One", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + capabilities: ["chat", "skills:read"], + ...overrides, + }; +} + +/** Minimal fetch stub routing the handful of paths the platform touches. */ +function stubFetch(routes: Record) { + return vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = typeof input === "string" ? input : String(input); + const path = url.replace(AGENT, ""); + const hit = routes[path]; + if (!hit) { + return new Response(JSON.stringify({ error: "not found" }), { status: 404 }); + } + return new Response(hit.body === undefined ? null : JSON.stringify(hit.body), { + status: hit.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }); +} + +beforeEach(() => { + __resetSessionRegistry(); +}); + +describe("credential handling", () => { + it("never persists the API key to localStorage", async () => { + const fetchMock = stubFetch({ + "/agent.manifest.json": { body: { agentId: "agent-1", displayName: "Agent One" } }, + "/api/studio/session": { body: sessionResponse() }, + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + const conn = await caps.connections.add({ + name: "agent", + baseUrl: AGENT, + apiKey: "hc_contrib_SUPERSECRET", + }); + + expect(conn.hasSession).toBe(true); + + // The key must not appear anywhere in persisted state. + const dump = JSON.stringify({ + ...localStorage, + keys: Object.keys(localStorage), + }); + expect(dump).not.toContain("hc_contrib_SUPERSECRET"); + + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key === null) continue; + expect(localStorage.getItem(key)).not.toContain("hc_contrib_SUPERSECRET"); + } + + vi.unstubAllGlobals(); + }); + + it("keeps the session token retrievable in memory but not in storage", async () => { + const fetchMock = stubFetch({ + "/agent.manifest.json": { body: { agentId: "agent-1" } }, + "/api/studio/session": { body: sessionResponse({ token: "tok-marker-zzz" }) }, + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + const conn = await caps.connections.add({ name: "a", baseUrl: AGENT, apiKey: "k" }); + + expect(caps.session.token(conn.id)).toBe("tok-marker-zzz"); + expect(caps.session.info(conn.id)?.role).toBe("contribute"); + + // token itself must not be persisted + let persisted = ""; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key === null) continue; + persisted += `${key}${localStorage.getItem(key)}`; + } + expect(persisted).not.toContain("tok-marker-zzz"); + + vi.unstubAllGlobals(); + }); + + it("presents the raw API key as X-Api-Key, never as a session token", async () => { + const fetchMock = stubFetch({ + "/agent.manifest.json": { body: { agentId: "agent-1" } }, + "/api/studio/session": { body: sessionResponse() }, + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + await caps.connections.add({ name: "a", baseUrl: AGENT, apiKey: "hc_contrib_REAL" }); + + const exchangeCall = fetchMock.mock.calls.find(([url]) => + String(url).endsWith("/api/studio/session"), + ); + expect(exchangeCall).toBeDefined(); + + const init = exchangeCall?.[1] as RequestInit; + const headers = init.headers as Record; + + expect(headers["X-Api-Key"]).toBe("hc_contrib_REAL"); + // Regression guard: sending the key as a session token makes the agent + // reject the exchange with 401, which surfaced only in browser E2E. + expect(headers["X-Session-Token"]).toBeUndefined(); + + vi.unstubAllGlobals(); + }); + + it("surfaces an invalid key rather than storing an offline connection silently", async () => { + const fetchMock = stubFetch({ + "/agent.manifest.json": { body: { agentId: "agent-1" } }, + "/api/studio/session": { status: 401, body: { error: "Invalid API key" } }, + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + await expect( + caps.connections.add({ name: "a", baseUrl: AGENT, apiKey: "bad" }), + ).rejects.toThrow(AgentError); + + vi.unstubAllGlobals(); + }); + + it("notifies listeners when a session expires and forgets the token", async () => { + vi.useFakeTimers(); + const fetchMock = stubFetch({ + "/agent.manifest.json": { body: { agentId: "agent-1" } }, + "/api/studio/session": { + body: sessionResponse({ expiresAt: new Date(Date.now() + 5_000).toISOString() }), + }, + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + const conn = await caps.connections.add({ name: "a", baseUrl: AGENT, apiKey: "k" }); + + const seen: string[] = []; + caps.session.onExpired((id, reason) => seen.push(`${id}:${reason}`)); + + vi.advanceTimersByTime(6_000); + + expect(seen).toEqual([`${conn.id}:expired`]); + expect(caps.session.token(conn.id)).toBeNull(); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); +}); + +describe("connections CRUD", () => { + it("persists connections without credential fields", async () => { + const fetchMock = stubFetch({ + "/agent.manifest.json": { body: { agentId: "agent-1" } }, + "/api/studio/session": { body: sessionResponse() }, + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + await caps.connections.add({ name: "first", baseUrl: AGENT, apiKey: "k" }); + + const stored = localStorage.getItem("hercules-studio.connections.v1") ?? ""; + expect(JSON.parse(stored)).toHaveLength(1); + expect(stored).not.toContain("apiKey"); + expect(stored).not.toContain("token"); + + const list = await caps.connections.list(); + expect(list).toHaveLength(1); + expect(list[0].name).toBe("first"); + + vi.unstubAllGlobals(); + }); + + it("saves an offline agent instead of failing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }), + ); + + const caps = createWebCapabilities(); + const conn = await caps.connections.add({ name: "down", baseUrl: AGENT, apiKey: "k" }); + + expect(conn.status).toBe("offline"); + expect(conn.hasSession).toBe(false); + expect(await caps.connections.list()).toHaveLength(1); + + vi.unstubAllGlobals(); + }); + + it("refuses to rewrite identity fields through update()", async () => { + // Stub: without this the call hits whatever agent happens to run on 8421. + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 404 }))); + const caps = createWebCapabilities(); + await caps.connections.add({ name: "orig", baseUrl: AGENT, apiKey: "k" }); + const [conn] = await caps.connections.list(); + + const patched = await caps.connections.update(conn.id, { + name: "renamed", + baseUrl: "http://evil.example", + } as never); + + expect(patched.name).toBe("renamed"); + expect(patched.baseUrl).toBe(AGENT); + vi.unstubAllGlobals(); + }); + + it("rejects updates to an unknown connection", async () => { + const caps = createWebCapabilities(); + await expect(caps.connections.update("nope", { name: "x" })).rejects.toThrow(AgentError); + }); + + it("reports health through the public endpoint and caches the result", async () => { + const fetchMock = stubFetch({ "/api/health": { body: { status: "ok" } } }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + await caps.connections.add({ name: "a", baseUrl: AGENT, apiKey: "k" }); + const [conn] = await caps.connections.list(); + + const health = await caps.connections.healthCheck(conn.id); + expect(health.online).toBe(true); + + const list = await caps.connections.list(); + expect(list[0].status).toBe("online"); + + vi.unstubAllGlobals(); + }); +}); + +describe("discovery", () => { + it("probes /agent-card.json and flags agents that require auth", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "http://localhost:8421/agent.manifest.json") { + return new Response(JSON.stringify({ agentId: "a1", displayName: "A1", version: "1.0" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:8421/agent.manifest.json") { + return new Response(null, { status: 401 }); + } + return new Response(null, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const caps = createWebCapabilities(); + const found = await caps.scanner.discover(); + + const byUrl = Object.fromEntries(found.map((f) => [f.baseUrl, f])); + expect(byUrl[AGENT]).toMatchObject({ + agentId: "a1", + displayName: "A1", + authRequired: false, + foundVia: "wellknown", + }); + expect(byUrl["http://127.0.0.1:8421"]).toMatchObject({ authRequired: true }); + expect(found.some((f) => f.baseUrl === "http://localhost:5000")).toBe(false); + + vi.unstubAllGlobals(); + }); + + it("emits progress and returns an unsubscribe function", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 404 }))); + + const caps = createWebCapabilities(); + const seen: number[] = []; + const off = caps.scanner.onProgress((p) => seen.push(p.scanned)); + + await caps.scanner.discover(); + expect(seen.length).toBeGreaterThan(0); + + const countAfter = seen.length; + off(); + await caps.scanner.discover(); + expect(seen.length).toBe(countAfter); + + vi.unstubAllGlobals(); + }); +}); + +describe("files", () => { + it("reports File System Access API as unsupported when absent", () => { + const caps = createWebCapabilities(); + expect(caps.files.isSupported()).toBe(false); + }); + + it("falls back to a download when showSaveFilePicker is missing", async () => { + const caps = createWebCapabilities(); + const created: string[] = []; + const originalCreate = URL.createObjectURL; + URL.createObjectURL = vi.fn(() => { + created.push("blob"); + return "blob:mock"; + }) as unknown as typeof URL.createObjectURL; + URL.revokeObjectURL = vi.fn() as unknown as typeof URL.revokeObjectURL; + + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + const result = await caps.files.save("notes.md", "# hello"); + + expect(result).toBe(true); + expect(created).toEqual(["blob"]); + expect(clickSpy).toHaveBeenCalledOnce(); + + URL.createObjectURL = originalCreate; + vi.unstubAllGlobals(); + }); + + it("rejects pickAndRead with a clear error when unsupported", async () => { + const caps = createWebCapabilities(); + await expect(caps.files.pickAndRead()).rejects.toThrow(/File System Access/); + }); +}); + +describe("settings", () => { + it("returns defaults when nothing is stored", async () => { + const caps = createWebCapabilities(); + expect(await caps.settings.get()).toEqual(DEFAULT_SETTINGS); + }); + + it("merges partial updates without dropping unrelated fields", async () => { + const caps = createWebCapabilities(); + const after = await caps.settings.update({ theme: "light" }); + expect(after.theme).toBe("light"); + expect(after.locale).toBe(DEFAULT_SETTINGS.locale); + expect(after.scan.endpoints).toEqual(DEFAULT_SETTINGS.scan.endpoints); + }); + + it("normalises a legacy port-range scan config into endpoints", async () => { + localStorage.setItem( + "hercules-studio.state.v1", + JSON.stringify({ + theme: "dark", + scan: { portStart: 8421, portEnd: 8521, legacyPort: 5000, timeoutMs: 300 }, + }), + ); + + const caps = createWebCapabilities(); + const settings = await caps.settings.get(); + + // Unknown legacy keys must not leak through, and endpoints must be present. + expect(settings.scan.endpoints).toEqual(DEFAULT_SETTINGS.scan.endpoints); + expect(settings.scan).not.toHaveProperty("portStart"); + expect(settings.scan).not.toHaveProperty("enableProcessScan"); + }); +}); + +describe("storage resilience", () => { + it("falls back to defaults on corrupt JSON instead of throwing", async () => { + localStorage.setItem("hercules-studio.state.v1", "{not json"); + const caps = createWebCapabilities(); + await expect(caps.settings.get()).resolves.toEqual(DEFAULT_SETTINGS); + }); + + it("ignores a corrupt connections blob", async () => { + localStorage.setItem("hercules-studio.connections.v1", "<<<"); + const caps = createWebCapabilities(); + await expect(caps.connections.list()).resolves.toEqual([]); + }); + + it("migrates the legacy Electron-era blob and strips key fields", async () => { + localStorage.setItem( + "hercules-studio-mock", + JSON.stringify({ + connections: [ + { + id: "old-1", + name: "legacy", + baseUrl: "http://localhost:8421", + agentId: "a", + displayName: "Legacy", + status: "online", + apiKey: "hc_contrib_LEGACYSECRET", + }, + ], + consent: { consentVersion: 1, acceptedAt: "2026-01-01T00:00:00Z", type: "nonprofit", licenseKey: null }, + settings: { theme: "light" }, + }), + ); + + const caps = createWebCapabilities(); + + const list = await caps.connections.list(); + expect(list).toHaveLength(1); + expect(list[0].id).toBe("old-1"); + expect(list[0].hasSession).toBe(false); + + // Legacy secret must be dropped, not carried over. + expect(localStorage.getItem("hercules-studio.connections.v1")).not.toContain("LEGACYSECRET"); + // Old blob is consumed. + expect(localStorage.getItem("hercules-studio-mock")).toBeNull(); + + expect(await caps.license.getConsent()).toMatchObject({ type: "nonprofit" }); + expect((await caps.settings.get()).theme).toBe("light"); + }); + + it("does not clobber existing v1 state during migration", async () => { + localStorage.setItem("hercules-studio.state.v1", JSON.stringify({ theme: "dark", locale: "ru" })); + localStorage.setItem( + "hercules-studio-mock", + JSON.stringify({ settings: { theme: "light" }, connections: [] }), + ); + + const caps = createWebCapabilities(); + const settings = await caps.settings.get(); + expect(settings.locale).toBe("ru"); + expect(localStorage.getItem("hercules-studio-mock")).toBeNull(); + }); +}); + +describe("license + app", () => { + it("round-trips license consent", async () => { + const caps = createWebCapabilities(); + expect(await caps.license.getConsent()).toBeNull(); + await caps.license.acceptConsent("commercial", "LIC-123"); + expect(await caps.license.getConsent()).toMatchObject({ type: "commercial", licenseKey: "LIC-123" }); + }); + + it("returns a stable non-secret studio id", () => { + const caps = createWebCapabilities(); + const id = caps.app.studioId(); + expect(id).toMatch(/^studio-/); + expect(caps.app.studioId()).toBe(id); + expect(JSON.stringify(localStorage)).toContain(id); + }); +}); \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/platform/web.ts b/src/hercules-studio/renderer/src/platform/web.ts new file mode 100644 index 0000000..d2d5b25 --- /dev/null +++ b/src/hercules-studio/renderer/src/platform/web.ts @@ -0,0 +1,760 @@ +/** + * Web platform implementation. + * + * Everything here must run in a plain browser tab: no Node built-ins, no native + * modules, no local database. Credentials are held in a module-level Map that + * dies with the tab — nothing secret is ever written to localStorage. + */ + +import type { + Connection, + ConnectionStatus, + DiscoveredAgent, + HealthStatus, + LicenseConsent, + NewConnection, + PlatformCapabilities, + ScanProgress, + ScanSettings, + SessionExpiredReason, + SessionInfo, + StudioSettings, + WorkflowCredentials, + WorkflowDetailDto, + WorkflowListDto, +} from "./capabilities"; + +// ---- Storage keys (versioned) ---- + +const KEY_SETTINGS = "hercules-studio.state.v1"; +const KEY_CONNECTIONS = "hercules-studio.connections.v1"; +const KEY_LICENSE = "hercules-studio.license.v1"; +/** Non-secret client identifier for CheckIn/CheckOut. */ +const KEY_STUDIO_ID = "hercules-studio.identity.v1"; +/** Pre-migration blob written by the removed Electron-era mock API. */ +const LEGACY_KEY = "hercules-studio-mock"; + +export const DEFAULT_SCAN_SETTINGS: ScanSettings = { + endpoints: ["http://localhost:8421", "http://127.0.0.1:8421", "http://localhost:5000"], + enableRemoteDiscovery: true, + timeoutMs: 3000, +}; + +export const DEFAULT_SETTINGS: StudioSettings = { + theme: "dark", + locale: "en", + typingEffect: true, + compactMode: false, + scan: { ...DEFAULT_SCAN_SETTINGS }, + notifications: { + enabled: true, + consensus: true, + workflow: true, + escalation: true, + chat: false, + }, + autoUpdate: true, + minimizeToTray: false, +}; + +// ---- Safe storage helpers ---- + +/** + * Reads and parses a versioned key. Corrupt data must never break startup: + * we fall back to the default and surface a warning instead of throwing. + */ +function readJson(key: string, fallback: T, onCorrupt?: (key: string) => void): T { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw) as T; + } catch { + onCorrupt?.(key); + return fallback; + } +} + +function writeJson(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Quota or private-mode failure — non-fatal, state stays in memory only. + } +} + +// ---- Session registry (memory only) ---- + +interface SessionRecord { + info: SessionInfo; + token: string; +} + +const sessions = new Map(); +const expiryTimers = new Map>(); +const expiryListeners = new Set<(connectionId: string, reason: SessionExpiredReason) => void>(); + +function scheduleExpiry(record: SessionRecord): void { + const existing = expiryTimers.get(record.info.connectionId); + if (existing) clearTimeout(existing); + + const ttl = new Date(record.info.expiresAt).getTime() - Date.now(); + if (!Number.isFinite(ttl) || ttl <= 0) { + // Already expired (clock skew or short TTL) — surface on next read. + return; + } + // setTimeout caps at ~24.8 days; re-arm for longer horizons. + const delay = Math.min(ttl, 2_147_483_000); + const timer = setTimeout(() => { + const current = sessions.get(record.info.connectionId); + if (current && current.token === record.token) { + dropSession(record.info.connectionId, "expired"); + } + }, delay); + expiryTimers.set(record.info.connectionId, timer); +} + +function dropSession(connectionId: string, reason: SessionExpiredReason): void { + const timer = expiryTimers.get(connectionId); + if (timer) { + clearTimeout(timer); + expiryTimers.delete(connectionId); + } + if (!sessions.delete(connectionId)) return; + for (const listener of expiryListeners) { + try { + listener(connectionId, reason); + } catch { + /* a broken listener must not block the others */ + } + } +} + +// ---- HTTP helper ---- + +export interface AgentRequestInit { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + /** Session token (from `session.exchange`). Sent as `X-Session-Token`. */ + token?: string | null; + /** Raw API key. Only ever valid on the one-shot exchange call. Sent as `X-Api-Key`. */ + apiKey?: string; + timeoutMs?: number; +} + +async function agentFetch(baseUrl: string, path: string, init: AgentRequestInit = {}): Promise { + const { method = "GET", body, token, apiKey, timeoutMs = 10000 } = init; + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["X-Session-Token"] = token; + if (apiKey) headers["X-Api-Key"] = apiKey; + + let res: Response; + try { + res = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (e) { + throw new AgentError( + e instanceof Error && e.name === "TimeoutError" ? `Timeout after ${timeoutMs}ms` : "Network unreachable", + 0, + ); + } + + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const payload = await res.json(); + if (payload?.error) message = String(payload.error); + } catch { + /* keep status-based message */ + } + throw new AgentError(message, res.status); + } + + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export class AgentError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "AgentError"; + } +} + +// ---- Legacy migration ---- + +interface LegacyBlob { + connections?: Connection[]; + consent?: LicenseConsent | null; + settings?: Partial; +} + +/** + * One-shot migration from the removed mock storage. Strips anything that is not + * an explicit `Connection` field so an old blob cannot reintroduce key fields. + */ +function migrateLegacy(): void { + let legacy: LegacyBlob; + try { + const raw = localStorage.getItem(LEGACY_KEY); + if (!raw) return; + legacy = JSON.parse(raw) as LegacyBlob; + } catch { + localStorage.removeItem(LEGACY_KEY); + return; + } + + try { + if (Array.isArray(legacy.connections) && !localStorage.getItem(KEY_CONNECTIONS)) { + const cleaned = legacy.connections.map((c) => ({ + id: String(c.id ?? crypto.randomUUID()), + name: String(c.name ?? "agent"), + baseUrl: String(c.baseUrl ?? ""), + agentId: String(c.agentId ?? "unknown"), + displayName: String(c.displayName ?? c.name ?? "agent"), + authScheme: "apikey", + lastSeen: c.lastSeen ?? null, + status: (c.status ?? "offline") as ConnectionStatus, + checkedOut: false, + checkedOutBy: null, + hasSession: false, + })); + writeJson(KEY_CONNECTIONS, cleaned); + } + + if (legacy.consent && !localStorage.getItem(KEY_LICENSE)) { + writeJson(KEY_LICENSE, legacy.consent); + } + + if (legacy.settings && !localStorage.getItem(KEY_SETTINGS)) { + writeJson(KEY_SETTINGS, { ...DEFAULT_SETTINGS, ...legacy.settings }); + } + } finally { + localStorage.removeItem(LEGACY_KEY); + } +} + +// ---- Discovery ---- + +interface AgentCardShape { + agentId?: string; + displayName?: string; + name?: string; + version?: string; + url?: string; +} + +async function probeWellKnown(endpoint: string, timeoutMs: number): Promise { + const base = endpoint.replace(/\/$/, ""); + try { + const res = await fetch(`${base}/agent.manifest.json`, { + signal: AbortSignal.timeout(timeoutMs), + headers: { Accept: "application/json" }, + }); + if (res.status === 401 || res.status === 403) { + // Agent is up but requires auth — report it so the UI can prompt for a key. + return { + agentId: null, + displayName: null, + endpoint: `${base}/agent.manifest.json`, + baseUrl: base, + version: null, + authRequired: true, + foundVia: "wellknown", + }; + } + if (!res.ok) return null; + + const card = (await res.json()) as AgentCardShape; + return { + agentId: card.agentId ?? null, + displayName: card.displayName ?? card.name ?? null, + endpoint: `${base}/agent.manifest.json`, + baseUrl: base, + version: card.version ?? null, + authRequired: false, + foundVia: "wellknown", + }; + } catch { + return null; + } +} + +// ---- Progress fan-out ---- + +const progressListeners = new Set<(p: ScanProgress) => void>(); + +function emitProgress(p: ScanProgress): void { + for (const listener of progressListeners) { + try { + listener(p); + } catch { + /* isolate listener failures */ + } + } +} + +/** + * Rebuilds a ScanSettings from an untrusted blob. + * + * A spread merge is not safe here: the legacy Electron-era config used + * portStart/portEnd/legacyPort/enableProcessScan, and those keys would survive + * into the new shape. Only known keys are read. + */ +function normalizeScan(raw: unknown): ScanSettings { + if (!raw || typeof raw !== "object") return { ...DEFAULT_SCAN_SETTINGS }; + const s = raw as Partial; + return { + endpoints: Array.isArray(s.endpoints) && s.endpoints.length > 0 ? [...s.endpoints] : [...DEFAULT_SCAN_SETTINGS.endpoints], + enableRemoteDiscovery: typeof s.enableRemoteDiscovery === "boolean" ? s.enableRemoteDiscovery : DEFAULT_SCAN_SETTINGS.enableRemoteDiscovery, + timeoutMs: typeof s.timeoutMs === "number" && s.timeoutMs > 0 ? s.timeoutMs : DEFAULT_SCAN_SETTINGS.timeoutMs, + }; +} + +function normalizeNotifications(raw: unknown): StudioSettings["notifications"] { + if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS.notifications }; + const n = raw as Partial; + return { + enabled: typeof n.enabled === "boolean" ? n.enabled : DEFAULT_SETTINGS.notifications.enabled, + consensus: typeof n.consensus === "boolean" ? n.consensus : DEFAULT_SETTINGS.notifications.consensus, + workflow: typeof n.workflow === "boolean" ? n.workflow : DEFAULT_SETTINGS.notifications.workflow, + escalation: typeof n.escalation === "boolean" ? n.escalation : DEFAULT_SETTINGS.notifications.escalation, + chat: typeof n.chat === "boolean" ? n.chat : DEFAULT_SETTINGS.notifications.chat, + }; +} + +// ---- Workflow server access (separate service, own credentials) ---- + +let workflowCreds: WorkflowCredentials | null = null; + +async function workflowFetch(path: string, init: { method?: string; body?: unknown } = {}): Promise { + if (!workflowCreds) throw new AgentError("Workflow server is not configured", 0); + const headers: Record = { "Content-Type": "application/json" }; + if (workflowCreds.clientId) headers["X-Client-Id"] = workflowCreds.clientId; + if (workflowCreds.clientSecret) headers["X-Client-Secret"] = workflowCreds.clientSecret; + + const res = await fetch(`${workflowCreds.baseUrl}${path}`, { + method: init.method ?? "GET", + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const body = (await res.json()) as { error?: string; detail?: string; title?: string }; + message = body.error ?? body.detail ?? body.title ?? message; + } catch { + /* keep status-based message */ + } + throw new AgentError(message, res.status); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +// ---- Capability implementation ---- + +export function createWebCapabilities(): PlatformCapabilities { + migrateLegacy(); + + const loadConnections = (): Connection[] => readJson(KEY_CONNECTIONS, []); + const saveConnections = (list: Connection[]): void => writeJson(KEY_CONNECTIONS, list); + + return { + connections: { + async list(): Promise { + return loadConnections(); + }, + + async add(conn: NewConnection): Promise { + const baseUrl = conn.baseUrl.replace(/\/$/, ""); + if (!baseUrl) throw new AgentError("baseUrl is required", 0); + + // Exchange the key for a session token now. A failure here is surfaced to + // the caller but the connection is still saved, so an offline agent can + // be configured before it is started. + let agentId = "unknown"; + let displayName = conn.name; + let status: ConnectionStatus = "offline"; + let authenticated = false; + + const id = crypto.randomUUID(); + try { + const probe = await probeWellKnown(baseUrl, 3000); + if (probe && !probe.authRequired) { + agentId = probe.agentId ?? "unknown"; + displayName = probe.displayName ?? conn.name; + } + const session = await exchangeSession(id, baseUrl, conn.apiKey); + agentId = session.agentId || agentId; + displayName = session.displayName || displayName; + status = "online"; + authenticated = true; + } catch (e) { + status = "offline"; + if (e instanceof AgentError && (e.status === 401 || e.status === 403)) { + throw new AgentError("Invalid API key", e.status); + } + } + + const connection: Connection = { + id, + name: conn.name, + baseUrl, + agentId, + displayName, + authScheme: "session", + lastSeen: new Date().toISOString(), + status, + checkedOut: false, + checkedOutBy: null, + hasSession: authenticated, + }; + + const list = loadConnections(); + list.push(connection); + saveConnections(list); + return connection; + }, + + async remove(id: string): Promise { + dropSession(id, "invalidated"); + saveConnections(loadConnections().filter((c) => c.id !== id)); + }, + + async update(id: string, patch: Partial): Promise { + const list = loadConnections(); + const idx = list.findIndex((c) => c.id === id); + if (idx === -1) throw new AgentError("Connection not found", 404); + // Identity and credential fields are not client-writable. + const { id: _i, baseUrl: _b, ...safe } = patch; + list[idx] = { ...list[idx], ...safe }; + saveConnections(list); + return list[idx]; + }, + + async healthCheck(id: string): Promise { + const conn = loadConnections().find((c) => c.id === id); + if (!conn) { + return { online: false, agentId: null, displayName: null, latencyMs: null, error: "Not found" }; + } + + // Public health endpoint — no token required. + const started = Date.now(); + try { + const res = await fetch(`${conn.baseUrl}/api/health`, { + signal: AbortSignal.timeout(3000), + }); + const latencyMs = Date.now() - started; + const health: HealthStatus = { + online: res.ok, + agentId: conn.agentId, + displayName: conn.displayName, + latencyMs, + error: res.ok ? null : `HTTP ${res.status}`, + }; + patchStatus(id, health); + return health; + } catch (e) { + const health: HealthStatus = { + online: false, + agentId: conn.agentId, + displayName: conn.displayName, + latencyMs: null, + error: e instanceof Error ? e.message : String(e), + }; + patchStatus(id, health); + return health; + } + }, + + async setActive(): Promise { + // Active-connection selection is UI state, owned by the connections store. + }, + }, + + scanner: { + async discover(): Promise { + const settings = readJson(KEY_SETTINGS, DEFAULT_SETTINGS); + const known = loadConnections().map((c) => c.baseUrl); + const endpoints = [...new Set([...known, ...settings.scan.endpoints])]; + + const found: DiscoveredAgent[] = []; + let scanned = 0; + + // Bounded concurrency: browsers cap ~6 connections per host anyway. + const queue = [...endpoints]; + const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { + for (;;) { + const endpoint = queue.shift(); + if (!endpoint) return; + scanned += 1; + emitProgress({ scanned, total: endpoints.length, found: found.length, currentEndpoint: endpoint }); + const hit = await probeWellKnown(endpoint, settings.scan.timeoutMs); + if (hit) found.push(hit); + } + }); + await Promise.all(workers); + + emitProgress({ scanned, total: endpoints.length, found: found.length, currentEndpoint: "" }); + return found; + }, + + onProgress(cb: (p: ScanProgress) => void): () => void { + progressListeners.add(cb); + return () => progressListeners.delete(cb); + }, + }, + + files: { + isSupported(): boolean { + return typeof window !== "undefined" && "showOpenFilePicker" in window; + }, + + async pickAndRead(accept): Promise<{ name: string; content: string } | null> { + if (!this.isSupported()) { + throw new AgentError("File System Access API unavailable in this browser", 0); + } + const picker = ( + window as unknown as { + showOpenFilePicker: (opts?: unknown) => Promise }>>; + } + ).showOpenFilePicker; + let handles: Array<{ name: string; getFile(): Promise }>; + try { + handles = await picker({ multiple: false, types: accept ? [{ description: "Files", accept }] : undefined }); + } catch (e) { + // AbortError === user cancelled. + if (e instanceof DOMException && e.name === "AbortError") return null; + throw e; + } + if (!handles?.[0]) return null; + const file = await handles[0].getFile(); + return { name: handles[0].name, content: await file.text() }; + }, + + async save(suggestedName: string, content: string): Promise { + const w = window as unknown as { + showSaveFilePicker?: (opts?: unknown) => Promise<{ + createWritable: () => Promise<{ write: (d: BlobPart) => Promise; close: () => Promise }>; + }>; + }; + if (typeof w.showSaveFilePicker !== "function") { + // Fallback for Firefox/Safari: trigger a normal download. + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = suggestedName; + a.click(); + URL.revokeObjectURL(url); + return true; + } + try { + const handle = await w.showSaveFilePicker({ suggestedName }); + const writable = await handle.createWritable(); + await writable.write(content); + await writable.close(); + return true; + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") return false; + throw e; + } + }, + }, + + notify: { + supported(): boolean { + return typeof window !== "undefined" && "Notification" in window; + }, + permission(): NotificationPermission { + return this.supported() ? Notification.permission : "denied"; + }, + async request(): Promise { + if (!this.supported()) return "denied"; + return Notification.requestPermission(); + }, + async show(title: string, body: string): Promise { + if (!this.supported() || Notification.permission !== "granted") return; + new Notification(title, { body }); + }, + }, + + license: { + async getConsent(): Promise { + return readJson(KEY_LICENSE, null); + }, + async acceptConsent(type, key): Promise { + writeJson(KEY_LICENSE, { + consentVersion: 1, + acceptedAt: new Date().toISOString(), + type, + licenseKey: key ?? null, + } satisfies LicenseConsent); + }, + }, + + settings: { + async get(): Promise { + const stored = readJson>(KEY_SETTINGS, {}); + // Normalise rather than spread: unknown legacy keys must not survive. + return { + ...DEFAULT_SETTINGS, + ...stored, + scan: normalizeScan(stored.scan), + notifications: normalizeNotifications(stored.notifications), + }; + }, + async update(patch): Promise { + const current = await this.get(); + const next: StudioSettings = { + ...current, + ...patch, + scan: patch.scan ? normalizeScan(patch.scan) : current.scan, + notifications: patch.notifications + ? normalizeNotifications(patch.notifications) + : current.notifications, + }; + writeJson(KEY_SETTINGS, next); + return next; + }, + }, + + session: { + async exchange(connectionId, baseUrl, apiKey): Promise { + return exchangeSession(connectionId, baseUrl, apiKey); + }, + info(connectionId): SessionInfo | null { + return sessions.get(connectionId)?.info ?? null; + }, + token(connectionId): string | null { + return sessions.get(connectionId)?.token ?? null; + }, + drop(connectionId): void { + dropSession(connectionId, "invalidated"); + }, + onExpired(cb): () => void { + expiryListeners.add(cb); + return () => expiryListeners.delete(cb); + }, + }, + + workflows: { + configure(creds): void { + // Memory only — deliberately NOT persisted, mirroring the agent key rule. + // A reload asks the operator for the credentials again. + workflowCreds = { + baseUrl: creds.baseUrl.replace(/\/$/, ""), + clientId: creds.clientId, + clientSecret: creds.clientSecret, + }; + }, + configured(): boolean { + return workflowCreds !== null; + }, + baseUrl(): string | null { + return workflowCreds?.baseUrl ?? null; + }, + async list(opts = {}): Promise { + const query = new URLSearchParams(); + if (opts.limit) query.set("limit", String(opts.limit)); + if (opts.cursor) query.set("cursor", opts.cursor); + const qs = query.toString(); + return workflowFetch(`/api/workflows${qs ? `?${qs}` : ""}`); + }, + async get(id: string): Promise { + return workflowFetch(`/api/workflows/${encodeURIComponent(id)}`); + }, + async remove(id: string): Promise { + await workflowFetch(`/api/workflows/${encodeURIComponent(id)}`, { method: "DELETE" }); + }, + async run(id: string): Promise { + return workflowFetch(`/api/workflows/${encodeURIComponent(id)}/run`, { method: "POST" }); + }, + }, + + app: { + version(): string { + return typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "0.0.0-dev"; + }, + studioId(): string { + const existing = localStorage.getItem(KEY_STUDIO_ID); + if (existing) return existing; + const generated = `studio-${crypto.randomUUID()}`; + try { + localStorage.setItem(KEY_STUDIO_ID, generated); + } catch { + // Non-fatal: a per-tab id still works for CheckIn. + } + return generated; + }, + }, + }; + + function patchStatus(id: string, health: HealthStatus): void { + const list = loadConnections(); + const idx = list.findIndex((c) => c.id === id); + if (idx === -1) return; + list[idx].status = health.online ? "online" : "offline"; + list[idx].lastSeen = new Date().toISOString(); + saveConnections(list); + } +} + +/** Shape returned by `POST /api/studio/session` on the agent. */ +export interface SessionResponse { + token: string; + role: "contribute" | "system"; + agentId: string; + displayName: string; + expiresAt: string; + capabilities: string[]; +} + +/** Exchanges a contribute key for a short-lived session token held in memory. */ +async function exchangeSession( + connectionId: string, + baseUrl: string, + apiKey: string, +): Promise { + const response = await agentFetch(baseUrl, "/api/studio/session", { + method: "POST", + // The raw key authenticates this one call; every later request uses the token. + apiKey, + timeoutMs: 5000, + }); + + const record: SessionRecord = { + info: { + connectionId, + role: response.role, + agentId: response.agentId, + displayName: response.displayName, + expiresAt: response.expiresAt, + capabilities: response.capabilities ?? [], + }, + token: response.token, + }; + sessions.set(connectionId, record); + scheduleExpiry(record); + return record.info; +} + +/** Test seam: clears all in-memory state between specs. */ +export function __resetSessionRegistry(): void { + for (const timer of expiryTimers.values()) clearTimeout(timer); + expiryTimers.clear(); + sessions.clear(); + progressListeners.clear(); + expiryListeners.clear(); + // Workflow credentials are memory-only; never let one leak into the next spec. + workflowCreds = null; +} \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/sdk/client.ts b/src/hercules-studio/renderer/src/sdk/client.ts index aeae613..542dddd 100644 --- a/src/hercules-studio/renderer/src/sdk/client.ts +++ b/src/hercules-studio/renderer/src/sdk/client.ts @@ -1,40 +1,49 @@ import type { AgentManifest, + ApprovalDto, + ApprovalsResponse, ChatResponseDto, ConfigDto, + EscalationDto, + EscalationsResponse, MeshDashboardDto, + MeshHealthDto, SkillDto, SkillDetailDto, StatsDto, + ToolDto, + ToolsResponse, } from "./types"; +/** + * HTTP client for a Hercules agent. + * + * Authentication is a short-lived session token obtained via + * `POST /api/studio/session` and held only in memory by the platform layer. + * The token is read through a provider on every request so a re-exchange is + * picked up without rebuilding the client. + * + * System-role operations (config PUT, restart, MCP mutation) are authorised by + * the agent based on the token's role; a contribute-role token gets 403 and the + * caller must request a system session. + */ export class HerculesClient { - private baseUrl: string; - private apiKey: string; - private systemKey: string | null = null; + private readonly baseUrl: string; + private readonly tokenProvider: () => string | null; - constructor(baseUrl: string, apiKey: string) { + constructor(baseUrl: string, tokenProvider: () => string | null) { this.baseUrl = baseUrl.replace(/\/$/, ""); - this.apiKey = apiKey; + this.tokenProvider = tokenProvider; } - setSystemKey(key: string | null) { - this.systemKey = key; + hasSession(): boolean { + return this.tokenProvider() !== null; } - hasSystemKey(): boolean { - return this.systemKey !== null; - } - - private headers(json = true): Record { - const h: Record = { "X-Api-Key": this.apiKey }; - if (json) h["Content-Type"] = "application/json"; - return h; - } - - private systemHeaders(json = true): Record { - if (!this.systemKey) throw new Error("System key not set"); - const h: Record = { "X-Api-Key": this.systemKey }; + private authHeaders(json = true): Record { + const token = this.tokenProvider(); + if (!token) throw new Error("No active session — re-enter the API key"); + const h: Record = { "X-Session-Token": token }; if (json) h["Content-Type"] = "application/json"; return h; } @@ -50,46 +59,61 @@ export class HerculesClient { } throw new Error(msg); } + if (res.status === 204) return undefined as T; return res.json() as Promise; } + private async request( + path: string, + init: { method?: string; body?: unknown; auth?: boolean; timeoutMs?: number } = {}, + ): Promise { + const { method = "GET", body, auth = true, timeoutMs = 30_000 } = init; + const headers = auth ? this.authHeaders(body !== undefined) : {}; + return this.handle( + await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }), + ); + } + // ---- System ---- + /** Public health endpoint; requires no session. */ async getHealth(): Promise<{ status: string }> { - const res = await fetch(`${this.baseUrl}/api/health`); - return this.handle(res); + return this.handle<{ status: string }>( + await fetch(`${this.baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) }), + ); } async getManifest(): Promise { - const res = await fetch(`${this.baseUrl}/agent.manifest.json`, { - headers: { "X-Api-Key": this.apiKey }, - }); - return this.handle(res); + return this.request("/agent.manifest.json", { auth: false }); } // ---- Chat ---- - async chat(message: string): Promise { - const res = await fetch(`${this.baseUrl}/api/chat`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ message }), - }); - return this.handle(res); + async chat(message: string, sessionId?: string): Promise { + const headers: Record = { ...this.authHeaders(), "X-Session-Id": sessionId ?? "default" }; + return this.handle( + await fetch(`${this.baseUrl}/api/chat`, { + method: "POST", + headers, + body: JSON.stringify({ message }), + signal: AbortSignal.timeout(120_000), + }), + ); } // ---- Skills ---- async listSkills(): Promise { - return this.handle( - await fetch(`${this.baseUrl}/api/skills`, { headers: this.headers(false) }), - ); + return this.request("/api/skills"); } async getSkill(id: string): Promise { - return this.handle( - await fetch(`${this.baseUrl}/api/skills/${id}`, { headers: this.headers(false) }), - ); + return this.request(`/api/skills/${encodeURIComponent(id)}`); } async createSkill(data: { @@ -98,87 +122,241 @@ export class HerculesClient { prompt: string; description?: string; }): Promise { - const res = await fetch(`${this.baseUrl}/api/skills`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify(data), - }); - return this.handle(res); + return this.request("/api/skills", { method: "POST", body: data }); } async updateSkill( id: string, data: { triggers?: string[]; prompt?: string; description?: string }, ): Promise { - const res = await fetch(`${this.baseUrl}/api/skills/${id}`, { - method: "PUT", - headers: this.headers(), - body: JSON.stringify(data), - }); - return this.handle(res); + return this.request(`/api/skills/${encodeURIComponent(id)}`, { method: "PUT", body: data }); + } + + async deleteSkill(id: string): Promise { + await this.request(`/api/skills/${encodeURIComponent(id)}`, { method: "DELETE" }); } async improveSkill(id: string): Promise { - const res = await fetch(`${this.baseUrl}/api/skills/${id}/improve`, { - method: "POST", - headers: this.headers(false), - }); - return this.handle(res); + return this.request(`/api/skills/${encodeURIComponent(id)}/improve`, { method: "POST" }); } async evaluateSkill(id: string): Promise<{ skillId: string; score: number; passed: boolean }> { - const res = await fetch(`${this.baseUrl}/api/skills/${id}/evaluate`, { - method: "POST", - headers: this.headers(false), - }); - return this.handle(res); + return this.request<{ skillId: string; score: number; passed: boolean }>( + `/api/skills/${encodeURIComponent(id)}/evaluate`, + { method: "POST" }, + ); } // ---- Stats ---- async stats(): Promise { - return this.handle( - await fetch(`${this.baseUrl}/api/stats`, { headers: this.headers(false) }), - ); + return this.request("/api/stats"); } // ---- Config ---- async getConfig(): Promise { - return this.handle( - await fetch(`${this.baseUrl}/api/config`, { headers: this.headers(false) }), - ); + return this.request("/api/config"); } async patchConfig(patch: Record): Promise { - const res = await fetch(`${this.baseUrl}/api/config`, { - method: "PATCH", - headers: this.headers(), - body: JSON.stringify(patch), - }); - return this.handle(res); + return this.request("/api/config", { method: "PATCH", body: patch }); } + /** Requires a system-role session. */ async putConfig(body: unknown): Promise { - const res = await fetch(`${this.baseUrl}/api/config`, { - method: "PUT", - headers: this.systemHeaders(), - body: JSON.stringify(body), - }); - return this.handle(res); + return this.request("/api/config", { method: "PUT", body }); } // ---- Mesh ---- async getMeshDashboard(): Promise { - return this.handle( - await fetch(`${this.baseUrl}/api/mesh/dashboard`, { headers: this.headers(false) }), - ); + return this.request("/api/mesh/dashboard"); } async getMeshAgents(): Promise { - return this.handle( - await fetch(`${this.baseUrl}/api/mesh/agents`, { headers: this.headers(false) }), - ); + return this.request("/api/mesh/agents"); + } + + async getMeshHealth(): Promise { + return this.request("/api/mesh/health"); + } + + /** Recent policy denials, newest first. */ + async getMeshDenials(limit = 20): Promise { + return this.request(`/api/mesh/denials?limit=${limit}`); + } + + // ---- Human-in-the-loop: approvals + escalations ---- + + async getPendingApprovals(): Promise { + return this.request("/api/approvals/pending"); } + + async approveApproval(id: string): Promise { + return this.request(`/api/approvals/${encodeURIComponent(id)}/approve`, { method: "POST" }); + } + + async denyApproval(id: string): Promise { + return this.request(`/api/approvals/${encodeURIComponent(id)}/deny`, { method: "POST" }); + } + + async getPendingEscalations(minSeverity?: string): Promise { + const q = minSeverity ? `?minSeverity=${encodeURIComponent(minSeverity)}` : ""; + return this.request(`/api/escalations/pending${q}`); + } + + async approveEscalation(id: string): Promise { + return this.request(`/api/escalations/${encodeURIComponent(id)}/approve`, { method: "POST" }); + } + + async denyEscalation(id: string): Promise { + return this.request(`/api/escalations/${encodeURIComponent(id)}/deny`, { method: "POST" }); + } + + async batchApproveEscalations(ids: string[]): Promise { + return this.request("/api/escalations/batch-approve", { method: "POST", body: { ids } }); + } + + // ---- Tools / MCP ---- + + async listTools(): Promise { + return this.request("/api/tools"); + } + + async enableTool(name: string): Promise { + return this.request(`/api/tools/${encodeURIComponent(name)}/enable`, { method: "POST" }); + } + + async disableTool(name: string): Promise { + return this.request(`/api/tools/${encodeURIComponent(name)}/disable`, { method: "POST" }); + } + + async listMcpServers(): Promise { + return this.request("/api/mcp/servers"); + } + + // ---- Restart (supervisor-driven) ---- + + /** + * Requests an agent restart. The agent only sets a flag; `Hercules.Supervisor` + * polls `/api/system/restart-pending` and performs the actual kill/start. + * Requires a system-role session. + */ + async requestRestart(reason: string): Promise { + return this.request("/api/system/restart", { method: "POST", body: { reason } }); + } + + async getRestartPending(): Promise { + return this.request("/api/system/restart-pending", { auth: false }); + } + + // ---- Sandbox code runs (ADR-0009) ---- + + /** Starts a sandbox run and returns its id. Execution continues in the background. */ + async startCodeRun(req: { + code: string; + language?: string; + args?: string[]; + timeoutMs?: number; + skillId?: string; + }): Promise<{ runId: string }> { + return this.request<{ runId: string }>("/api/code/run", { method: "POST", body: req, timeoutMs: 15_000 }); + } + + async getCodeRun(runId: string): Promise { + return this.request(`/api/code/run/${encodeURIComponent(runId)}`); + } + + /** + * Subscribes to a run's SSE output. + * + * Uses fetch + a manual parser rather than EventSource because the stream is + * authenticated with `X-Session-Token`, which EventSource cannot send. + * + * Returns an unsubscribe function. + */ + streamCodeRun( + runId: string, + onEvent: (type: string, data: unknown) => void, + options: { after?: number; signal?: AbortSignal } = {}, + ): () => void { + const token = this.tokenProvider(); + if (!token) throw new Error("No active session"); + + const controller = new AbortController(); + if (options.signal) { + options.signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + + const cursor = options.after ?? 0; + void (async () => { + try { + const res = await fetch( + `${this.baseUrl}/api/code/run/${encodeURIComponent(runId)}/stream?after=${cursor}`, + { headers: { Accept: "text/event-stream", "X-Session-Token": token }, signal: controller.signal }, + ); + if (!res.ok || !res.body) { + onEvent("error", { message: `stream failed: HTTP ${res.status}` }); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE frames are separated by a blank line. + let split: number; + while ((split = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, split); + buffer = buffer.slice(split + 2); + + let type = "message"; + const dataLines: string[] = []; + for (const line of frame.split("\n")) { + if (line.startsWith("event:")) type = line.slice(6).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); + } + if (dataLines.length === 0) continue; + try { + onEvent(type, JSON.parse(dataLines.join("\n"))); + } catch { + onEvent(type, dataLines.join("\n")); + } + } + } + } catch (e) { + if (!controller.signal.aborted) { + onEvent("error", { message: e instanceof Error ? e.message : String(e) }); + } + } + })(); + + return () => controller.abort(); + } +} + +export interface CodeRunStatus { + runId: string; + skillId: string | null; + language: string; + status: "queued" | "running" | "completed" | "failed"; + startedAt: string; + completedAt: string | null; + error: string | null; + result: { + exitCode: number; + stdout: string; + stderr: string; + durationMs: number; + status: string; + blockedPatterns: string[]; + sessionDir: string | null; + success: boolean; + } | null; } \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/sdk/types.ts b/src/hercules-studio/renderer/src/sdk/types.ts index a4c6a0d..e3fdf5b 100644 --- a/src/hercules-studio/renderer/src/sdk/types.ts +++ b/src/hercules-studio/renderer/src/sdk/types.ts @@ -65,6 +65,70 @@ export interface AgentManifest { supported_protocol_versions?: string[]; } +// Tool registry types — mirrors ToolRegistryController.ToDto +export interface ToolDto { + name: string; + category: string; + description: string; + enabled: boolean; + allowed: boolean; + registeredAt: string; + source: string; + supportsHealthCheck: boolean; + healthStatus: string; + lastCheckedAt: string | null; + lastError: string | null; + consecutiveFailures: number; + sideEffectLevel: string | null; + requiredPermissions: string | null; + timeoutSeconds: number; + limits: { maxCallsPerMinute: number; timeoutSeconds: number } | null; +} + +export interface ToolsResponse { + count: number; + allowedCount: number; + tools: ToolDto[]; +} + +// Human-in-the-loop types — mirrors ApprovalController.ToDto / EscalationController.ToDto +export interface ApprovalDto { + requestId: string; + sessionId: string; + toolName: string; + argumentsJson: string; + reason: string; + requestedAt: string; + status: string; +} + +export interface ApprovalsResponse { + count: number; + approvals: ApprovalDto[]; +} + +export interface EscalationDto { + escalationId: string; + requestId: string; + sessionId: string; + type: string; + severity: string; + status: string; + actionPlan: string; + context: string; + payloadJson: string; + toolOrIntentName: string; + requestedBy: string; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; +} + +export interface EscalationsResponse { + count: number; + escalations: EscalationDto[]; +} + // Mesh types export interface MeshAgentDto { agentId: string; diff --git a/src/hercules-studio/renderer/src/serviceWorker.ts b/src/hercules-studio/renderer/src/serviceWorker.ts new file mode 100644 index 0000000..fda6c32 --- /dev/null +++ b/src/hercules-studio/renderer/src/serviceWorker.ts @@ -0,0 +1,34 @@ +/** + * Service worker registration (Phase 6). + * + * Registered only in production builds. In dev it would cache hashed assets and + * make every change require a manual cache purge, which is a bad trade for a + * tool whose whole point is quick iteration. + * + * The worker is deliberately conservative — see sw.js for the caching policy. + */ +export function registerServiceWorker(): void { + if (!import.meta.env.PROD) return; + if (!("serviceWorker" in navigator)) return; + + // BASE_URL is "/ui/" (see vite.config.ts) so the worker scope matches where + // the agent actually serves the app. + const swUrl = `${import.meta.env.BASE_URL}sw.js`; + + window.addEventListener("load", () => { + navigator.serviceWorker + .register(swUrl, { + // The agent serves /ui/sw.js with `Cache-Control: immutable, max-age=1y` + // (it is treated like any hashed asset). Without this the browser would + // reuse the cached worker script for a year and users would never get + // shell updates. `none` forces a revalidation of sw.js itself on every + // registration check. + updateViaCache: "none", + }) + .catch((err) => { + // A failed registration must never break the app; offline support is a + // progressive enhancement. + console.warn("[studio] service worker registration failed", err); + }); + }); +} \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/stores/connections.ts b/src/hercules-studio/renderer/src/stores/connections.ts index 257df3a..9903187 100644 --- a/src/hercules-studio/renderer/src/stores/connections.ts +++ b/src/hercules-studio/renderer/src/stores/connections.ts @@ -1,30 +1,64 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; -import type { Connection, NewConnection, DiscoveredAgent } from "@shared/protocol"; +import { platform, AgentError } from "@renderer/platform"; +import type { + Connection, + DiscoveredAgent, + HealthStatus, + NewConnection, + ScanProgress, +} from "@renderer/platform/capabilities"; import { HerculesClient } from "../sdk/client"; import { useToastStore } from "./toast"; +/** Agent CheckIn TTL is 60s; refresh well inside that window. */ +const HEARTBEAT_INTERVAL_MS = 20_000; +/** Never poll health faster than this for one connection. */ +const HEALTH_MIN_INTERVAL_MS = 5_000; +const BACKOFF_MIN_MS = 1_000; +const BACKOFF_MAX_MS = 30_000; + +export interface BusyAgent { + connectionId: string; + /** Whoever currently holds the exclusive contribute slot, per the agent. */ + holder: string | null; +} + export const useConnectionsStore = defineStore("connections", () => { const list = ref([]); const activeId = ref(null); const discovered = ref([]); const scanning = ref(false); + const scanProgress = ref(null); const loading = ref(false); const showAddForm = ref(false); + /** Values the add-connection dialog should prefill (e.g. from a scan result). */ + const addFormPrefill = ref<{ name: string; baseUrl: string } | null>(null); const client = ref(null); const error = ref(null); + const busyAgent = ref(null); + /** Set when a session token dies, so the UI can ask for the key again. */ + const sessionExpiredId = ref(null); const active = computed(() => list.value.find((c) => c.id === activeId.value) ?? null); const onlineCount = computed(() => list.value.filter((c) => c.status === "online").length); + let heartbeatTimer: ReturnType | null = null; + let monitorTimer: ReturnType | null = null; + let unsubscribeProgress: (() => void) | null = null; + let unsubscribeExpiry: (() => void) | null = null; + const lastHealthAt = new Map(); + async function load(): Promise { loading.value = true; error.value = null; try { - list.value = await window.studioAPI.connections.list(); + list.value = await platform.connections.list(); + if (list.value.length > 0 && !activeId.value) { + await setActive(list.value[0].id); + } } catch (e) { error.value = e instanceof Error ? e.message : String(e); - console.error("[connections.load]", error.value); } finally { loading.value = false; } @@ -33,16 +67,13 @@ export const useConnectionsStore = defineStore("connections", () => { async function add(conn: NewConnection): Promise { const toast = useToastStore(); try { - const newConn = await window.studioAPI.connections.add(conn); - list.value.push(newConn); - // Store contribute key for later use by HerculesClient (in browser mock) - if (typeof localStorage !== "undefined") { - localStorage.setItem(`mock-key-${newConn.id}`, conn.apiKey); - } - if (!activeId.value) { - await setActive(newConn.id); - } + const created = await platform.connections.add(conn); + list.value.push(created); + if (!activeId.value) await setActive(created.id); showAddForm.value = false; + if (created.status === "offline") { + toast.warn(`Added ${created.name} — agent not reachable yet.`); + } } catch (e) { const msg = e instanceof Error ? e.message : String(e); error.value = msg; @@ -54,18 +85,18 @@ export const useConnectionsStore = defineStore("connections", () => { async function remove(id: string): Promise { const toast = useToastStore(); try { - await window.studioAPI.connections.remove(id); + if (activeId.value === id) { + stopHeartbeat(); + await checkout(); + } + await platform.connections.remove(id); list.value = list.value.filter((c) => c.id !== id); + lastHealthAt.delete(id); + if (activeId.value === id) { activeId.value = null; client.value = null; - if (list.value.length > 0) { - await setActive(list.value[0].id); - } - } - // Clear mock key - if (typeof localStorage !== "undefined") { - localStorage.removeItem(`mock-key-${id}`); + if (list.value.length > 0) await setActive(list.value[0].id); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -76,24 +107,42 @@ export const useConnectionsStore = defineStore("connections", () => { } async function setActive(id: string): Promise { + if (activeId.value === id && client.value) return; + if (activeId.value) { + stopHeartbeat(); + await checkout(); + } activeId.value = id; - try { - await window.studioAPI.connections.setActive(id); - // Build HerculesClient with contribute key from safeStorage - const conn = list.value.find((c) => c.id === id); - if (conn) { - const key = await window.studioAPI.keys.getContributeKey(id); - if (key) { - client.value = new HerculesClient(conn.baseUrl, key); - } - } - } catch (e) { - console.error("[connections.setActive]", e); + + const conn = list.value.find((c) => c.id === id); + if (!conn) return; + + // The token lives only in the platform's memory map; this closure reads it + // per request so a re-exchange is picked up without rebuilding the client. + client.value = new HerculesClient(conn.baseUrl, () => platform.session.token(id)); + + if (platform.session.token(id)) { + void checkin(conn); + startHeartbeat(); } } - async function healthCheck(id: string) { - const status = await window.studioAPI.connections.healthCheck(id); + async function healthCheck(id: string): Promise { + const now = Date.now(); + const last = lastHealthAt.get(id) ?? 0; + if (now - last < HEALTH_MIN_INTERVAL_MS) { + const conn = list.value.find((c) => c.id === id); + return { + online: conn?.status === "online", + agentId: conn?.agentId ?? null, + displayName: conn?.displayName ?? null, + latencyMs: null, + error: null, + }; + } + lastHealthAt.set(id, now); + + const status = await platform.connections.healthCheck(id); const idx = list.value.findIndex((c) => c.id === id); if (idx !== -1) { list.value[idx].status = status.online ? "online" : "offline"; @@ -102,35 +151,238 @@ export const useConnectionsStore = defineStore("connections", () => { return status; } + /** Opens the app-level add-connection dialog, optionally prefilled. */ + function openAddForm(init?: { name?: string; baseUrl?: string }): void { + addFormPrefill.value = init ? { name: init.name ?? "", baseUrl: init.baseUrl ?? "" } : null; + showAddForm.value = true; + } + + function closeAddForm(): void { + showAddForm.value = false; + addFormPrefill.value = null; + } + async function scan(): Promise { scanning.value = true; error.value = null; + discovered.value = []; try { - discovered.value = await window.studioAPI.scanner.scan(); + unsubscribeProgress?.(); + unsubscribeProgress = platform.scanner.onProgress((p) => { + scanProgress.value = p; + }); + discovered.value = await platform.scanner.discover(); } catch (e) { error.value = e instanceof Error ? e.message : String(e); - console.error("[connections.scan]", error.value); } finally { + unsubscribeProgress?.(); + unsubscribeProgress = null; scanning.value = false; } } + /** Re-runs the key exchange for a connection whose session token died. */ + async function reauthenticate(id: string, apiKey: string): Promise { + const conn = list.value.find((c) => c.id === id); + if (!conn) return; + try { + await platform.session.exchange(id, conn.baseUrl, apiKey); + sessionExpiredId.value = null; + const idx = list.value.findIndex((c) => c.id === id); + if (idx !== -1) list.value[idx].hasSession = true; + if (activeId.value === id) { + startHeartbeat(); + void checkin(conn); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + useToastStore().error(`Authentication failed: ${msg}`); + throw e; + } + } + + function acknowledgeExpiry(): void { + sessionExpiredId.value = null; + } + + function dispose(): void { + stopHeartbeat(); + stopMonitor(); + unsubscribeProgress?.(); + unsubscribeProgress = null; + unsubscribeExpiry?.(); + unsubscribeExpiry = null; + } + + // ---- CheckIn / CheckOut ---- + + async function checkin(conn: Connection): Promise { + const token = platform.session.token(conn.id); + if (!token) return; + try { + const res = await platformFetch(conn, "/api/system/checkin", "POST", token, { + studioId: platform.app.studioId(), + studioName: "Hercules Studio", + }); + if (res?.checkoutRequired || res?.busy) { + busyAgent.value = { + connectionId: conn.id, + holder: typeof res.holder === "string" ? res.holder : null, + }; + } + const idx = list.value.findIndex((c) => c.id === conn.id); + if (idx !== -1) { + list.value[idx].checkedOut = true; + list.value[idx].checkedOutBy = platform.app.studioId(); + } + } catch (e) { + if (e instanceof AgentError && (e.status === 409 || e.status === 423)) { + // Exclusive slot is held elsewhere — a normal condition, not an error. + busyAgent.value = { connectionId: conn.id, holder: null }; + return; + } + // Transient failure: heartbeat will retry. + } + } + + async function checkout(): Promise { + const conn = active.value; + if (!conn) return; + const token = platform.session.token(conn.id); + if (!token) return; + try { + await platformFetch(conn, "/api/system/checkout", "POST", token, { + studioId: platform.app.studioId(), + studioName: "Hercules Studio", + }); + } catch { + // Best-effort: the 60s TTL will release the slot anyway. + } + const idx = list.value.findIndex((c) => c.id === conn.id); + if (idx !== -1) list.value[idx].checkedOut = false; + } + + function startHeartbeat(): void { + stopHeartbeat(); + + const tick = async () => { + // A backgrounded tab still needs to refresh before the TTL lapses. + const conn = active.value; + if (conn) await checkin(conn); + }; + heartbeatTimer = setInterval(() => void tick(), HEARTBEAT_INTERVAL_MS); + + // Browsers throttle timers in background tabs; re-arm on return. + document.addEventListener("visibilitychange", onVisibility); + } + + function stopHeartbeat(): void { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + document.removeEventListener("visibilitychange", onVisibility); + } + + function onVisibility(): void { + if (document.visibilityState === "visible" && heartbeatTimer) { + void (async () => { + const conn = active.value; + if (conn) await checkin(conn); + })(); + } + } + + /** + * Polls the active agent with exponential backoff. The first probe is fast so + * a restarted agent is picked up quickly; sustained failure backs off to 30s + * so an offline agent does not burn requests. + */ + function startMonitor(): void { + stopMonitor(); + let delay = BACKOFF_MIN_MS; + + const probe = async (): Promise => { + const conn = active.value; + if (!conn || document.visibilityState === "hidden") { + monitorTimer = setTimeout(() => void probe(), delay); + return; + } + const health = await healthCheck(conn.id); + delay = health.online ? BACKOFF_MIN_MS : Math.min(delay * 2, BACKOFF_MAX_MS); + monitorTimer = setTimeout(() => void probe(), delay); + }; + + monitorTimer = setTimeout(() => void probe(), BACKOFF_MIN_MS); + } + + function stopMonitor(): void { + if (monitorTimer) { + clearTimeout(monitorTimer); + monitorTimer = null; + } + } + + /** Wire once at app start so token expiry prompts for a key. */ + function initSessionWatcher(): void { + unsubscribeExpiry?.(); + unsubscribeExpiry = platform.session.onExpired((connectionId) => { + sessionExpiredId.value = connectionId; + const idx = list.value.findIndex((c) => c.id === connectionId); + if (idx !== -1) list.value[idx].hasSession = false; + useToastStore().warn("Session expired — re-enter the API key to continue."); + }); + } + + async function platformFetch( + conn: Connection, + path: string, + method: "POST", + token: string, + body: unknown, + ): Promise | null> { + const res = await fetch(`${conn.baseUrl}${path}`, { + method, + headers: { "Content-Type": "application/json", "X-Session-Token": token }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new AgentError(`HTTP ${res.status}`, res.status); + if (res.status === 204) return null; + try { + return (await res.json()) as Record; + } catch { + return null; + } + } + return { list, activeId, active, + onlineCount, discovered, scanning, + scanProgress, loading, showAddForm, - onlineCount, + addFormPrefill, + openAddForm, + closeAddForm, client, error, + busyAgent, + sessionExpiredId, load, add, remove, setActive, healthCheck, scan, + reauthenticate, + acknowledgeExpiry, + startMonitor, + dispose, + initSessionWatcher, }; }); \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/stores/settings.ts b/src/hercules-studio/renderer/src/stores/settings.ts index 45971ea..0b4076a 100644 --- a/src/hercules-studio/renderer/src/stores/settings.ts +++ b/src/hercules-studio/renderer/src/stores/settings.ts @@ -1,49 +1,25 @@ import { defineStore } from "pinia"; import { ref } from "vue"; -import type { StudioSettings } from "@shared/protocol"; - -const DEFAULT_SETTINGS: StudioSettings = { - theme: "dark", - locale: "en", - typingEffect: true, - compactMode: false, - scan: { - portStart: 8421, - portEnd: 8521, - legacyPort: 5000, - enableProcessScan: true, - autoScanOnStartup: true, - concurrent: 50, - timeoutMs: 300, - }, - notifications: { - enabled: true, - consensus: true, - workflow: true, - escalation: true, - chat: false, - }, - autoUpdate: true, - minimizeToTray: false, -}; +import { platform, DEFAULT_SETTINGS } from "@renderer/platform"; +import type { StudioSettings } from "@renderer/platform/capabilities"; export const useSettingsStore = defineStore("settings", () => { - const data = ref(DEFAULT_SETTINGS); + const data = ref({ ...DEFAULT_SETTINGS }); const loaded = ref(false); - async function load() { - data.value = await window.studioAPI.settings.get(); - loaded.value = true; - applyTheme(); + function applyTheme(): void { + document.documentElement.classList.toggle("dark", data.value.theme === "dark"); } - async function update(patch: Partial) { - data.value = await window.studioAPI.settings.update(patch); + async function load(): Promise { + data.value = await platform.settings.get(); + loaded.value = true; applyTheme(); } - function applyTheme() { - document.documentElement.classList.toggle("dark", data.value.theme === "dark"); + async function update(patch: Partial): Promise { + data.value = await platform.settings.update(patch); + applyTheme(); } return { data, loaded, load, update }; diff --git a/src/hercules-studio/renderer/src/stores/toast.ts b/src/hercules-studio/renderer/src/stores/toast.ts index f7bb367..9eb3f9d 100644 --- a/src/hercules-studio/renderer/src/stores/toast.ts +++ b/src/hercules-studio/renderer/src/stores/toast.ts @@ -34,6 +34,11 @@ export const useToastStore = defineStore("toast", () => { show(message, "warning", duration); } + /** Alias matching the common `warn()` convention. */ + function warn(message: string, duration?: number): void { + show(message, "warning", duration); + } + function error(message: string, duration?: number): void { show(message, "error", duration ?? 6000); } @@ -46,5 +51,5 @@ export const useToastStore = defineStore("toast", () => { toasts.value = []; } - return { toasts, show, info, success, warning, error, dismiss, clear }; + return { toasts, show, info, success, warning, warn, error, dismiss, clear }; }); \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/stores/ui.ts b/src/hercules-studio/renderer/src/stores/ui.ts new file mode 100644 index 0000000..3f70ef7 --- /dev/null +++ b/src/hercules-studio/renderer/src/stores/ui.ts @@ -0,0 +1,34 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +/** + * Shared UI state. + * + * The active view used to live as a local ref in App.vue, which meant no view + * could navigate to another one (e.g. Chat proposing a skill edit). Keeping it + * in a store makes cross-view navigation a one-liner. + */ +export const useUiStore = defineStore("ui", () => { + const activeView = ref("agents"); + /** Skill to open when navigating to the skills view. */ + const focusedSkillId = ref(null); + /** Bumped to ask the active view to reload (e.g. after a skill is created). */ + const refreshToken = ref(0); + + function select(viewId: string): void { + activeView.value = viewId; + } + + /** Navigate to a skill. Re-selecting the same view still works as a refresh. */ + function openSkill(skillId: string): void { + focusedSkillId.value = skillId; + refreshToken.value += 1; + activeView.value = "skills"; + } + + function requestRefresh(): void { + refreshToken.value += 1; + } + + return { activeView, focusedSkillId, refreshToken, select, openSkill, requestRefresh }; +}); \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/test/setup.ts b/src/hercules-studio/renderer/src/test/setup.ts new file mode 100644 index 0000000..349d187 --- /dev/null +++ b/src/hercules-studio/renderer/src/test/setup.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach } from "vitest"; +import { __resetSessionRegistry } from "../platform/web"; +import { __resetCapabilities } from "../platform"; + +// happy-dom does not always provide crypto.randomUUID. +if (typeof globalThis.crypto === "undefined") { + Object.defineProperty(globalThis, "crypto", { value: {}, writable: true }); +} +if (typeof globalThis.crypto.randomUUID !== "function") { + let counter = 0; + globalThis.crypto.randomUUID = () => { + counter += 1; + const rand = Math.random().toString(36).slice(2, 10); + return `00000000-0000-4000-8000-${String(counter).padStart(4, "0")}${rand}` as `${string}-${string}-${string}-${string}-${string}`; + }; +} + +beforeEach(() => { + localStorage.clear(); + __resetSessionRegistry(); + __resetCapabilities(); +}); + +afterEach(() => { + __resetSessionRegistry(); +}); \ No newline at end of file diff --git a/src/hercules-studio/renderer/src/views/AgentList.vue b/src/hercules-studio/renderer/src/views/AgentList.vue index ca46a07..5358155 100644 --- a/src/hercules-studio/renderer/src/views/AgentList.vue +++ b/src/hercules-studio/renderer/src/views/AgentList.vue @@ -1,96 +1,37 @@ + +