merge: A30 — workflow canvas, agent files, live workspace

This commit is contained in:
Hermes Team 2026-08-26 08:52:38 +07:00
commit f5d4826e7d
17 changed files with 1587 additions and 42 deletions

View file

@ -0,0 +1,43 @@
# Задание Antigravity: полный release gate после A30
## Цель
Провести независимую проверку ветки `codex/workflow-canvas` после реализации A30. Проверять фактическое состояние репозитория и запускаемого приложения, а не описание работы.
## Обязательный порядок
1. Получить актуальные `origin/main` и `origin/codex/workflow-canvas`.
2. Проверить `git status`, базовый и финальный SHA ветки.
3. Запустить приложение из чистого checkout ветки A30.
4. Выполнить полный `pytest`/release gate и сохранить полный вывод.
5. Выполнить `ruff check .`.
6. Проверить веб-контракт: `/`, `/api/snapshot`, `/api/events`, `/api/action`.
7. Проверить A30 вручную в браузере: LIVE, EDIT, создание агента, назначение Provider → Account → Model, Agent File, редактор ребра, цикл и предел итераций.
8. Отдельно проверить честность данных: отсутствие mock/demo чисел из макета, `Н/Д` с причиной, loading не смешан с отсутствием данных.
9. Проверить persistence после перезапуска и реальные provider errors.
10. Проверить, что desktop `router/ui/**` не изменён A30.
## Правила отчёта
- Не писать `PASS`, если полный release gate не запускался.
- Не считать targeted tests заменой полного regression.
- Для каждого failure привести команду, stdout/stderr, файл и минимальный способ воспроизведения.
- Если блокер связан с окружением, повторить проверку в чистом окружении или явно указать, что именно не проверено.
## Артефакты
Передать:
- `START_HEAD`, `FINAL_HEAD`, `origin/main`;
- чистый `git status` или полный список загрязнений;
- `X passed / Y skipped / Z failed`;
- точный результат `scripts/release_gate.py`;
- список найденных дефектов с приоритетом P0P3;
- скриншоты LIVE, EDIT, Inspector, Agent File и редактора ребра;
- отдельный список пропущенных проверок.
## Ограничения
- Ничего не исправлять молча в чужой ветке: найденные дефекты оформить отдельным патчем/коммитом или вернуть владельцу.
- Не удалять пользовательские изменения в установщике, бинарниках и заданиях inbox.
- Не объявлять release-ready при известных блокерах.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -90,6 +90,24 @@ readiness, agents, providers, routing, quotas, metrics, is_stale
Имена действий берутся **ровно** из общего слоя `action_handler.py`:
Действия Agent Manager и Workflow (A30):
| Action | Назначение | Обязательные данные |
|---|---|---|
| `create_agent` | Создать логического агента, роль маршрутизатора и Agent File | `name`, `role`; опционально `profile_id`, `model`, настройки исполнения |
| `update_agent` | Изменить свойства и назначение Provider → Account → Model | `agent_id`; назначение задаётся `provider`, `profile_id`, `model` |
| `delete_agent` | Удалить агента; при ссылках сначала возвращает `confirmation_required` | `agent_id`; после подтверждения `force: true` |
| `read_agent_file` | Прочитать реальный Markdown Agent File | `agent_id` |
| `save_agent_file` | Атомарно сохранить Agent File для последующих запусков | `agent_id`, `content` |
| `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` |
| `start_workflow` | Запустить реальную задачу через RouterEngine | `task` |
| `stop_workflow` | Запросить остановку текущего запуска | — |
Состояние графа и LIVE-журнал приходят в поле `workflow` ответа
`GET /api/snapshot`. `workflow.run.status=loading` означает загрузку;
`unavailable_reason` означает отсутствие данных с явной причиной. Показатели
workflow нельзя подменять фикстурой при недоступности API.
```
account_details add_account agent_settings apply_update
assign_role auto_assign_all check_updates delete_credentials

View file

@ -85,6 +85,23 @@ def get_router_active_profile_path() -> Path:
return get_config_dir() / "router_active_profile.json"
def get_workflow_state_path() -> Path:
"""Return the persisted agent/workflow state sidecar.
Logical roles and their execution routes remain canonical in
``router_profiles.yaml``. This file stores only the extra agent metadata,
graph layout and execution checkpoints which do not belong to routing.
"""
return get_config_dir() / "workflow_state.json"
def get_agent_files_dir() -> Path:
"""Return the user-editable directory containing real Agent Files."""
directory = get_hermes_home() / "agents"
directory.mkdir(parents=True, exist_ok=True)
return directory
def get_compatibility_path() -> Path:
return get_config_dir() / "compatibility.json"

View file

@ -305,6 +305,23 @@ class ActionExecutor:
except Exception:
pass
if action in {
'create_agent',
'update_agent',
'delete_agent',
'read_agent_file',
'save_agent_file',
'save_workflow',
'start_workflow',
'stop_workflow',
}:
try:
from antigravity_provider.router.workflow_service import execute_workflow_action
return execute_workflow_action(action, data)
except (ValueError, OSError) as exc:
return {'ok': False, 'message': str(exc)}
# Device-flow для Grok и Codex через веб. Backend был готов давно, но
# наружу не выведен: веб-мастер показывал заглушку «не реализовано», и
# подключить эти провайдеры можно было только из десктопа. Настоящие

View file

@ -55,6 +55,7 @@ class HubSnapshot:
quotas: Dict[str, Any]
metrics: Dict[str, Any] = field(default_factory=dict)
is_stale: bool = False
workflow: Dict[str, Any] = field(default_factory=dict)
def get_profile(self, profile_id: str) -> Optional[ProfileViewModel]:
return self.all_profiles.get(profile_id)
@ -107,6 +108,24 @@ class HubStateStore:
if self._current_snapshot is not None:
if (time.time() - self._current_snapshot.timestamp > 300.0) and not self._current_snapshot.is_stale:
self._current_snapshot = replace(self._current_snapshot, is_stale=True)
# Provider/account scans are intentionally cached, while LIVE
# workflow checkpoints are small local state and must never lag
# behind an action until the next expensive provider refresh.
try:
from .workflow_service import WorkflowService
live_workflow = WorkflowService.get().snapshot()
role_views = {agent.role_id: agent for agent in self._current_snapshot.agents}
for workflow_agent in live_workflow.get("agents", []):
role_view = role_views.get(workflow_agent.get("role"))
generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title()
if role_view and workflow_agent.get("name") == generic_name:
workflow_agent["name"] = role_view.role_name_ru
if role_view and not workflow_agent.get("description"):
workflow_agent["description"] = role_view.role_description_ru
self._current_snapshot = replace(self._current_snapshot, workflow=live_workflow)
except Exception:
pass
return self._current_snapshot
return self.refresh(force_scan=False)
@ -196,6 +215,27 @@ class HubStateStore:
"active_calls_total": active_leases_total,
"active_calls_by_profile": active_leases_by_profile,
}
try:
from .workflow_service import WorkflowService
workflow_data = WorkflowService.get().snapshot()
role_views = {agent.role_id: agent for agent in agents}
for workflow_agent in workflow_data.get("agents", []):
role_view = role_views.get(workflow_agent.get("role"))
generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title()
if role_view and workflow_agent.get("name") == generic_name:
workflow_agent["name"] = role_view.role_name_ru
if role_view and not workflow_agent.get("description"):
workflow_agent["description"] = role_view.role_description_ru
except Exception as exc:
workflow_data = {
"agents": [],
"definition": {},
"run": {"status": "unavailable"},
"events": [],
"is_loading": False,
"unavailable_reason": f"Workflow state unavailable: {exc}",
}
snapshot = HubSnapshot(
generation=gen,
seq=request_seq,
@ -209,6 +249,7 @@ class HubStateStore:
quotas=quotas_map,
metrics=metrics,
is_stale=False,
workflow=workflow_data,
)
self._current_snapshot = snapshot
@ -263,6 +304,13 @@ class HubStateStore:
"active_calls_by_profile": {},
},
is_stale=True,
workflow={
"agents": [],
"definition": {},
"run": {"status": "loading"},
"events": [],
"is_loading": True,
},
)
def _apply_profile_delta(self, profile: ProfileViewModel) -> HubSnapshot:

View file

@ -519,7 +519,7 @@ function updateGlobalHeader() {
const isHealthy = readiness.state === 'healthy';
const readyRoles = readiness.roles_ready_count || 0;
const totalRoles = readiness.total_roles || 6;
const totalRoles = readiness.total_roles ?? 0;
if (elements.headerReadinessBadge) {
elements.headerReadinessBadge.className = `header-readiness-badge ${isHealthy ? 'text-healthy' : 'text-warning'}`;
@ -527,7 +527,7 @@ function updateGlobalHeader() {
if (elements.headerReadinessText) {
elements.headerReadinessText.textContent = readiness.title_ru
? `${readiness.title_ru} (${readyRoles}/${totalRoles} ролей)`
: 'Система готова';
: 'Н/Д: состояние ещё не измерено';
}
const kpiReadiness = document.getElementById('kpi-system-readiness');
@ -805,6 +805,10 @@ function renderQuotaCell(bucket, unavailableReason) {
// 1. OVERVIEW VIEW (P0-3, P0-4 Diagram Model Select & Counters)
// ═══════════════════════════════════════════════════════════════
function renderOverviewView() {
if (typeof renderWorkflowOverview === 'function') {
renderWorkflowOverview(currentSnapshot);
return;
}
if (!currentSnapshot) return;
const providers = currentSnapshot.providers || [];

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Hub — Панель управления</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="static/workflow.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
</head>
<body>
@ -92,52 +93,53 @@
<div class="content-scroll">
<!-- 1. OVERVIEW VIEW -->
<section id="view-overview" class="view-pane active">
<div class="overview-grid">
<div class="kpi-card">
<div class="kpi-label">Состояние системы</div>
<div class="kpi-value text-healthy" id="kpi-system-readiness"></div>
<div class="kpi-sub" id="kpi-readiness-summary"></div>
<div class="workflow-toolbar">
<div class="workflow-mode" role="group" aria-label="Режим workflow">
<button class="workflow-mode-btn active" id="workflow-mode-live" data-mode="live">LIVE</button>
<button class="workflow-mode-btn" id="workflow-mode-edit" data-mode="edit">EDIT</button>
</div>
<div class="kpi-card">
<div class="kpi-label">Подключено аккаунтов</div>
<div class="kpi-value text-accent" id="kpi-total-accounts"></div>
<div class="kpi-sub" id="kpi-accounts-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Готовых ролей</div>
<div class="kpi-value text-healthy" id="kpi-ready-roles"></div>
<div class="kpi-sub" id="kpi-roles-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Провайдеры ИИ</div>
<div class="kpi-value text-info" id="kpi-providers-count"></div>
<div class="kpi-sub" id="kpi-providers-sub">5 поддерживаемых систем</div>
<div class="workflow-toolbar-actions">
<span class="workflow-save-state" id="workflow-save-state">Сохранено</span>
<button class="btn btn-secondary" id="btn-workflow-save">Сохранить workflow</button>
<button class="btn btn-primary" id="btn-agent-add">+ Добавить агента</button>
</div>
</div>
<div class="section-card">
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<div class="section-card-title">Схема маршрутизации запросов</div>
<div class="section-card-subtitle">Распределение агентов и цепочки отказоустойчивости</div>
<div class="workflow-kpis" aria-label="Оперативные показатели">
<article class="workflow-kpi"><span>Активные задачи</span><strong id="workflow-kpi-active">Загрузка…</strong><small id="workflow-kpi-active-reason">Получение снапшота</small></article>
<article class="workflow-kpi"><span>Агенты онлайн</span><strong id="workflow-kpi-online">Загрузка…</strong><small id="workflow-kpi-online-reason">Получение снапшота</small></article>
<article class="workflow-kpi"><span>Среднее время ответа</span><strong id="workflow-kpi-latency">Загрузка…</strong><small id="workflow-kpi-latency-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Использование токенов</span><strong id="workflow-kpi-tokens">Загрузка…</strong><small id="workflow-kpi-tokens-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Успешность задач</span><strong id="workflow-kpi-success">Загрузка…</strong><small id="workflow-kpi-success-reason">Получение телеметрии</small></article>
</div>
<div class="workflow-main-layout">
<section class="workflow-board-card">
<header class="workflow-board-header">
<div><strong id="workflow-title">Workflow</strong> <span id="workflow-run-state" class="workflow-run-state">Загрузка…</span></div>
<label>Итерация: <strong id="workflow-iteration">Н</strong> / <input id="workflow-max-iterations" type="number" min="1" max="100" value="5"></label>
<div class="workflow-zoom"><button id="workflow-zoom-out" title="Уменьшить"></button><span id="workflow-zoom-value">100%</span><button id="workflow-zoom-in" title="Увеличить">+</button><button id="workflow-fit" title="Вписать граф"></button></div>
</header>
<div class="workflow-canvas" id="workflow-canvas" tabindex="0" aria-label="Редактор графа workflow">
<svg id="workflow-edges" class="workflow-edges" aria-hidden="true"><defs><marker id="wf-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"></path></marker></defs><g id="workflow-edge-layer"></g></svg>
<div id="workflow-node-layer" class="workflow-node-layer"></div>
<div id="workflow-empty" class="workflow-empty hidden"><strong>В workflow пока нет агентов</strong><span>Добавьте агента, затем переключитесь в EDIT и соедините узлы.</span></div>
<div class="workflow-minimap" id="workflow-minimap" aria-label="Мини-карта"></div>
</div>
<button class="btn btn-secondary btn-sm" id="btn-auto-assign" title="Автоматическое распределение аккаунтов по ролям">
Авто-распределение
</button>
</div>
<div class="route-diagram-container" id="overview-route-diagram">
<!-- Rendered by app.js -->
</div>
<footer class="workflow-legend">
<span><i class="state-waiting"></i>Ожидает</span><span><i class="state-working"></i>Работает</span><span><i class="state-reviewing"></i>Проверяет</span><span><i class="state-error"></i>Ошибка</span><span><i class="state-completed"></i>Завершено</span>
<span class="edge-legend success">→ Успех</span><span class="edge-legend return">⇢ Возврат</span>
</footer>
</section>
<aside class="workflow-inspector" id="workflow-inspector">
<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>
</aside>
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Провайдеры и доступность runtime</div>
<div class="section-card-subtitle">Статус локальных адаптеров и обнаруженные модели</div>
</div>
<div class="providers-grid" id="overview-providers-summary">
<!-- Rendered by app.js -->
</div>
<div class="workflow-bottom-grid">
<section class="workflow-events"><header><strong>Последние события</strong><button id="workflow-events-all">Журнал событий →</button></header><div id="workflow-events-list" class="workflow-events-list"><p>Загрузка событий…</p></div></section>
<section class="workflow-run-panel"><header><strong>Управление LIVE</strong></header><textarea id="workflow-task" placeholder="Опишите реальную задачу для workflow"></textarea><div><button class="btn btn-primary" id="btn-workflow-start">Запустить workflow</button><button class="btn btn-secondary" id="btn-workflow-stop">Остановить</button></div><p id="workflow-run-error" class="workflow-run-error"></p></section>
</div>
</section>
@ -505,5 +507,6 @@
<!-- Application Logic -->
<script src="app.js"></script>
<script src="static/workflow.js"></script>
</body>
</html>

View file

@ -0,0 +1,89 @@
/* A30 overview/workflow components. Theme colors come from the shared tokens. */
#view-overview { padding: 0; min-width: 860px; }
.workflow-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
.workflow-mode { display:flex; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-muted); }
.workflow-mode-btn { min-width:78px; border:0; border-radius:var(--radius-sm); padding:7px 16px; color:var(--text-muted); background:transparent; cursor:pointer; font-weight:700; }
.workflow-mode-btn.active { color:var(--text-primary); background:var(--surface-active); box-shadow:inset 0 0 0 1px var(--border-accent); }
.workflow-mode-btn:first-child.active { color:var(--status-healthy); }
.workflow-toolbar-actions { display:flex; gap:8px; align-items:center; }
.workflow-save-state { color:var(--text-muted); font-size:11px; }
.workflow-save-state.dirty { color:var(--status-warning); }
.workflow-kpis { display:grid; grid-template-columns:repeat(5,minmax(130px,1fr)); gap:8px; margin-bottom:10px; }
.workflow-kpi { padding:11px 13px; min-height:78px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:linear-gradient(145deg,var(--surface),var(--surface-muted)); }
.workflow-kpi span,.workflow-kpi small { display:block; color:var(--text-muted); }
.workflow-kpi strong { display:block; margin:4px 0 2px; font-size:20px; font-weight:650; color:var(--text-primary); }
.workflow-kpi small { font-size:10px; line-height:1.25; }
.workflow-main-layout { display:grid; grid-template-columns:minmax(580px,1fr) 330px; gap:10px; min-height:520px; }
.workflow-board-card,.workflow-inspector,.workflow-events,.workflow-run-panel { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; }
.workflow-board-header { min-height:45px; padding:8px 12px; display:flex; gap:18px; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); }
.workflow-board-header strong { color:var(--text-accent); text-transform:uppercase; }
.workflow-board-header label { margin-left:auto; font-size:11px; color:var(--text-muted); }
.workflow-board-header input { width:42px; padding:3px; color:var(--text-primary); background:var(--surface-muted); border:1px solid var(--border); border-radius:3px; }
.workflow-run-state { margin-left:8px; font-size:11px; color:var(--text-muted); }
.workflow-run-state.running { color:var(--status-healthy); }
.workflow-run-state.failed,.workflow-run-state.interrupted { color:var(--status-error); }
.workflow-zoom { display:flex; align-items:center; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
.workflow-zoom button { width:28px; height:27px; border:0; border-left:1px solid var(--border-subtle); color:var(--text-accent); background:transparent; cursor:pointer; }
.workflow-zoom span { min-width:44px; text-align:center; font-size:11px; }
.workflow-canvas { position:relative; height:430px; overflow:hidden; background-color:var(--bg-base); background-image:radial-gradient(var(--border-subtle) 1px,transparent 1px); background-size:18px 18px; }
.workflow-edges,.workflow-node-layer { position:absolute; inset:0; width:100%; height:100%; transform-origin:0 0; }
.workflow-edges { overflow:visible; pointer-events:none; }
.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); }
.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); }
.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; }
.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; }
.workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; }
.workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; }
.workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); }
.workflow-node.working { border-color:var(--status-healthy); box-shadow:0 0 20px rgba(114,201,67,.24); }
.workflow-node.error { border-color:var(--status-error); }
.workflow-node.completed { border-color:var(--status-disabled); }
.workflow-node h3 { margin:0 0 2px; font-size:13px; font-weight:650; color:var(--text-primary); }
.workflow-node p { overflow:hidden; margin:1px 0; color:var(--text-muted); font-size:10px; white-space:nowrap; text-overflow:ellipsis; }
.workflow-node-status { display:flex; align-items:center; gap:5px; margin-top:6px; font-size:10px; color:var(--text-secondary); }
.workflow-node-status i,.workflow-legend i { width:7px; height:7px; border-radius:50%; background:var(--status-info); }
.workflow-node.working .workflow-node-status i,.state-working { background:var(--status-healthy)!important; }
.workflow-node.reviewing .workflow-node-status i,.state-reviewing { background:var(--status-warning)!important; }
.workflow-node.error .workflow-node-status i,.state-error { background:var(--status-error)!important; }
.workflow-node.completed .workflow-node-status i,.state-completed { background:var(--status-disabled)!important; }
.workflow-port { display:none; position:absolute; top:37px; width:13px; height:22px; border:1px solid var(--accent); border-radius:8px; background:var(--surface-active); }
.workflow-mode-edit .workflow-port { display:block; }
.workflow-port.in { left:-8px; }
.workflow-port.out { right:-8px; cursor:crosshair; }
.workflow-legend { min-height:39px; padding:8px 12px; display:flex; align-items:center; gap:14px; flex-wrap:wrap; border-top:1px solid var(--border-subtle); color:var(--text-muted); font-size:10px; }
.workflow-legend span { display:flex; align-items:center; gap:5px; }
.edge-legend.success { color:var(--status-healthy); }.edge-legend.return { color:var(--status-error); }
.workflow-minimap { position:absolute; left:10px; bottom:10px; width:110px; height:68px; border:1px solid var(--border); background:rgba(6,25,22,.82); pointer-events:none; }
.workflow-minimap i { position:absolute; width:16px; height:9px; border:1px solid var(--accent); background:var(--surface-muted); }
.workflow-empty { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:6px; color:var(--text-muted); }
.workflow-inspector { min-width:0; padding:12px; overflow:auto; max-height:520px; }
.workflow-inspector-empty { height:100%; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:8px; color:var(--text-muted); }
.workflow-inspector h2 { margin:0; color:var(--text-accent); font:600 15px var(--font-title); }
.inspector-tabs { display:flex; gap:2px; margin:10px 0; border-bottom:1px solid var(--border-subtle); overflow-x:auto; }
.inspector-tabs button { padding:7px 6px; border:0; border-bottom:2px solid transparent; color:var(--text-muted); background:transparent; font-size:10px; cursor:pointer; }
.inspector-tabs button.active { color:var(--text-accent); border-color:var(--accent); }
.inspector-field { display:block; margin:9px 0; color:var(--text-muted); font-size:10px; }
.inspector-field input,.inspector-field select,.inspector-field textarea,.workflow-run-panel textarea,.workflow-dialog input,.workflow-dialog select,.workflow-dialog textarea { width:100%; margin-top:4px; padding:7px 8px; color:var(--text-primary); border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--surface-muted); font:12px var(--font-ui); }
.inspector-field textarea { min-height:58px; resize:vertical; }
.inspector-value { padding:7px 0; color:var(--text-secondary); font-size:11px; overflow-wrap:anywhere; }
.inspector-actions { display:flex; gap:7px; flex-wrap:wrap; margin-top:12px; }
.agent-file-card { margin-top:12px; padding:10px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
.agent-file-card code { display:block; margin:4px 0 8px; color:var(--text-secondary); overflow-wrap:anywhere; }
.workflow-bottom-grid { display:grid; grid-template-columns:1.4fr 1fr; gap:10px; margin-top:10px; }
.workflow-events,.workflow-run-panel { min-height:145px; padding:11px; }
.workflow-events header,.workflow-run-panel header { display:flex; justify-content:space-between; margin-bottom:8px; color:var(--text-secondary); }
.workflow-events header button { border:0; color:var(--text-accent); background:none; cursor:pointer; }
.workflow-events-list { max-height:110px; overflow:auto; }
.workflow-event { display:grid; grid-template-columns:72px 1fr auto; gap:8px; padding:5px 0; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:10px; }
.workflow-event time,.workflow-event em { color:var(--text-muted); font-style:normal; }
.workflow-event.error { color:var(--status-error); }
.workflow-run-panel textarea { min-height:68px; resize:vertical; }
.workflow-run-panel>div { display:flex; gap:8px; margin-top:8px; }
.workflow-run-error { margin-top:6px; color:var(--status-error); font-size:10px; }
.workflow-dialog-backdrop { position:fixed; inset:0; z-index:1200; display:flex; justify-content:center; align-items:center; padding:24px; background:var(--bg-modal-backdrop); }
.workflow-dialog { width:min(580px,100%); max-height:88vh; overflow:auto; padding:18px; border:1px solid var(--border-accent); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 24px 70px rgba(0,0,0,.45); }
.workflow-dialog h2 { margin-bottom:12px; color:var(--text-accent); }
.workflow-dialog textarea.agent-file-editor { min-height:380px; font-family:var(--font-mono); }
.workflow-dialog-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; }
.hidden { display:none!important; }
@media (max-width:1200px) { .workflow-kpis { grid-template-columns:repeat(3,1fr); }.workflow-main-layout { grid-template-columns:minmax(560px,1fr) 290px; } }

View file

@ -0,0 +1,497 @@
/* Hermes Hub A30 workflow canvas — vanilla JS, no build step. */
'use strict';
const workflowUi = {
initialized: false,
mode: 'live',
selectedAgentId: null,
selectedTab: 'main',
scale: 1,
dirty: false,
draftEdges: [],
draftPositions: {},
connectingFrom: null,
drag: null,
};
function wfEscape(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;',
})[char]);
}
function wfValue(value, suffix = '') {
return value === null || value === undefined ? null : `${value}${suffix}`;
}
function wfUnavailable(elementId, reason) {
const value = document.getElementById(elementId);
const detail = document.getElementById(`${elementId}-reason`);
if (value) value.textContent = 'Н/Д';
if (detail) detail.textContent = `Н/Д: ${reason}`;
}
function initWorkflowOverview() {
if (workflowUi.initialized) return;
workflowUi.initialized = true;
document.getElementById('workflow-mode-live')?.addEventListener('click', () => setWorkflowMode('live'));
document.getElementById('workflow-mode-edit')?.addEventListener('click', () => setWorkflowMode('edit'));
document.getElementById('btn-workflow-save')?.addEventListener('click', saveWorkflowDraft);
document.getElementById('btn-agent-add')?.addEventListener('click', openAgentCreateDialog);
document.getElementById('btn-workflow-start')?.addEventListener('click', startWorkflowRun);
document.getElementById('btn-workflow-stop')?.addEventListener('click', () => executeAction('stop_workflow', {}));
document.getElementById('workflow-events-all')?.addEventListener('click', () => switchView('logs'));
document.getElementById('workflow-max-iterations')?.addEventListener('change', markWorkflowDirty);
document.getElementById('workflow-zoom-in')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale + 0.1));
document.getElementById('workflow-zoom-out')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale - 0.1));
document.getElementById('workflow-fit')?.addEventListener('click', fitWorkflowGraph);
const canvas = document.getElementById('workflow-canvas');
canvas?.addEventListener('mousemove', workflowPointerMove);
canvas?.addEventListener('mouseup', workflowPointerUp);
canvas?.addEventListener('mouseleave', workflowPointerCancel);
window.addEventListener('resize', drawWorkflowEdges);
}
function renderWorkflowOverview(snapshot) {
initWorkflowOverview();
const workflow = snapshot?.workflow;
if (!workflow) {
renderWorkflowLoading('Backend не вернул поле workflow');
return;
}
if (workflow.is_loading) {
renderWorkflowLoading('Workflow загружается');
return;
}
if (workflow.unavailable_reason) {
renderWorkflowLoading(workflow.unavailable_reason);
return;
}
if (!workflowUi.dirty && !workflowUi.drag && !workflowUi.connectingFrom) {
workflowUi.draftEdges = (workflow.definition?.edges || []).map((edge) => ({ ...edge }));
workflowUi.draftPositions = Object.fromEntries((workflow.agents || []).map((agent) => [
agent.id, { ...(agent.position || { x: 80, y: 80 }) },
]));
}
renderWorkflowKpis(snapshot);
renderWorkflowHeader(workflow);
renderWorkflowNodes(workflow);
renderWorkflowInspector(snapshot, workflow);
renderWorkflowEvents(workflow.events || []);
requestAnimationFrame(drawWorkflowEdges);
}
function renderWorkflowLoading(reason) {
['workflow-kpi-active', 'workflow-kpi-online', 'workflow-kpi-latency', 'workflow-kpi-tokens', 'workflow-kpi-success']
.forEach((id) => wfUnavailable(id, reason));
const empty = document.getElementById('workflow-empty');
if (empty) {
empty.classList.remove('hidden');
empty.innerHTML = `<strong>Workflow недоступен</strong><span>${wfEscape(reason)}</span>`;
}
}
function renderWorkflowKpis(snapshot) {
const metrics = snapshot.metrics || {};
const telemetry = metrics.telemetry || {};
const global = telemetry.global || {};
const workflow = snapshot.workflow || {};
const run = workflow.run || {};
const active = run.status === 'running' ? 1 : 0;
setWorkflowKpi('workflow-kpi-active', String(active), 'Источник: workflow.run.status');
const readiness = snapshot.readiness || {};
if (readiness.roles_ready_count === null || readiness.roles_ready_count === undefined || readiness.total_roles === undefined) {
wfUnavailable('workflow-kpi-online', 'readiness не содержит число готовых ролей');
} else {
setWorkflowKpi('workflow-kpi-online', `${readiness.roles_ready_count} / ${readiness.total_roles}`, 'Источник: readiness');
}
if (!global.total_calls) {
wfUnavailable('workflow-kpi-latency', 'за 24 часа нет измеренных вызовов');
wfUnavailable('workflow-kpi-success', 'за 24 часа нет завершённых вызовов');
} else {
const latency = wfValue(global.latency_p50_ms, ' мс');
latency ? setWorkflowKpi('workflow-kpi-latency', latency, 'Медиана p50, telemetry') : wfUnavailable('workflow-kpi-latency', 'провайдер не вернул задержку');
const success = global.successful_calls / global.total_calls * 100;
setWorkflowKpi('workflow-kpi-success', `${success.toFixed(1)}%`, `${global.successful_calls} из ${global.total_calls}, telemetry`);
}
if (global.total_tokens === null || global.total_tokens === undefined) {
wfUnavailable('workflow-kpi-tokens', 'провайдеры не вернули usage');
} else {
setWorkflowKpi('workflow-kpi-tokens', new Intl.NumberFormat('ru-RU').format(global.total_tokens), 'Источник: telemetry usage, 24 ч');
}
}
function setWorkflowKpi(id, value, reason) {
const node = document.getElementById(id);
const detail = document.getElementById(`${id}-reason`);
if (node) node.textContent = value;
if (detail) detail.textContent = reason;
}
function renderWorkflowHeader(workflow) {
const definition = workflow.definition || {};
const run = workflow.run || {};
document.getElementById('workflow-title').textContent = definition.name || 'Workflow без названия';
const state = document.getElementById('workflow-run-state');
state.textContent = workflowRunLabel(run.status);
state.className = `workflow-run-state ${wfEscape(run.status || 'idle')}`;
document.getElementById('workflow-iteration').textContent = run.iteration || 'Н/Д';
if (!workflowUi.dirty) document.getElementById('workflow-max-iterations').value = definition.max_iterations || 1;
document.getElementById('workflow-run-error').textContent = run.error || '';
document.getElementById('btn-workflow-start').disabled = run.status === 'running' || run.status === 'stopping';
document.getElementById('btn-workflow-stop').disabled = run.status !== 'running';
}
function workflowRunLabel(status) {
return ({ idle: 'Нет активного запуска', running: '● Запущен', stopping: 'Останавливается', completed: 'Завершён', failed: 'Ошибка', stopped: 'Остановлен', interrupted: 'Прерван перезапуском' })[status] || `Н/Д: неизвестный статус ${status || 'не указан'}`;
}
function renderWorkflowNodes(workflow) {
const layer = document.getElementById('workflow-node-layer');
const canvas = document.getElementById('workflow-canvas');
const agents = workflow.agents || [];
canvas.classList.toggle('workflow-mode-edit', workflowUi.mode === 'edit');
document.getElementById('workflow-empty').classList.toggle('hidden', agents.length > 0);
layer.innerHTML = agents.map((agent) => {
const pos = workflowUi.draftPositions[agent.id] || agent.position || { x: 80, y: 80 };
const cfg = agent.execution_config || {};
const assignment = cfg.unavailable_reason
? `Н/Д: ${cfg.unavailable_reason}`
: [cfg.provider, cfg.model, cfg.account].filter(Boolean).join(' · ');
return `<article class="workflow-node ${wfEscape(agent.runtime_state)} ${workflowUi.selectedAgentId === agent.id ? 'selected' : ''}" data-agent-id="${wfEscape(agent.id)}" style="left:${Number(pos.x) || 0}px;top:${Number(pos.y) || 0}px">
<button class="workflow-port in" aria-label="Вход"></button><button class="workflow-port out" aria-label="Создать связь"></button>
<h3>${wfEscape(agent.name)}</h3><p>${wfEscape(agent.role)}</p><p>${wfEscape(agent.agent_file)}</p><p title="${wfEscape(assignment)}">${wfEscape(assignment)}</p>
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
</article>`;
}).join('');
layer.style.transform = `scale(${workflowUi.scale})`;
layer.querySelectorAll('.workflow-node').forEach((node) => {
node.addEventListener('click', () => selectWorkflowAgent(node.dataset.agentId));
node.addEventListener('mousedown', beginNodeDrag);
node.querySelector('.workflow-port.out')?.addEventListener('mousedown', beginConnection);
node.querySelector('.workflow-port.in')?.addEventListener('mouseup', finishConnection);
});
renderWorkflowMinimap(agents);
}
function runtimeStateLabel(state) {
return ({ waiting: 'Ожидает', working: 'Работает', reviewing: 'Проверяет', error: 'Ошибка', completed: 'Завершено', not_implemented: 'Исполнение не реализовано' })[state] || `Н/Д: ${state || 'статус не получен'}`;
}
function setWorkflowMode(mode) {
if (mode === 'edit' && currentSnapshot?.workflow?.run?.status === 'running') {
showToast('EDIT недоступен во время LIVE-выполнения', 'warning');
return;
}
workflowUi.mode = mode;
document.querySelectorAll('.workflow-mode-btn').forEach((button) => button.classList.toggle('active', button.dataset.mode === mode));
renderWorkflowOverview(currentSnapshot);
}
function selectWorkflowAgent(agentId) {
if (workflowUi.drag?.moved) return;
workflowUi.selectedAgentId = agentId;
renderWorkflowOverview(currentSnapshot);
}
function beginNodeDrag(event) {
if (workflowUi.mode !== 'edit' || event.target.classList.contains('workflow-port')) return;
const node = event.currentTarget;
const position = workflowUi.draftPositions[node.dataset.agentId] || { x: node.offsetLeft, y: node.offsetTop };
workflowUi.drag = { id: node.dataset.agentId, startX: event.clientX, startY: event.clientY, original: { ...position }, moved: false };
event.preventDefault();
}
function workflowPointerMove(event) {
if (workflowUi.drag) {
const drag = workflowUi.drag;
const dx = (event.clientX - drag.startX) / workflowUi.scale;
const dy = (event.clientY - drag.startY) / workflowUi.scale;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
workflowUi.draftPositions[drag.id] = { x: Math.max(0, drag.original.x + dx), y: Math.max(0, drag.original.y + dy) };
const node = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(drag.id)}"]`);
if (node) {
node.style.left = `${workflowUi.draftPositions[drag.id].x}px`;
node.style.top = `${workflowUi.draftPositions[drag.id].y}px`;
}
drawWorkflowEdges();
}
}
function workflowPointerUp(event) {
if (workflowUi.drag) {
if (workflowUi.drag.moved) markWorkflowDirty();
workflowUi.drag = null;
}
if (workflowUi.connectingFrom && event.target === document.getElementById('workflow-canvas')) {
workflowUi.connectingFrom = null;
showToast('Создание связи отменено', 'info');
}
}
function workflowPointerCancel() {
if (workflowUi.drag) {
workflowUi.draftPositions[workflowUi.drag.id] = workflowUi.drag.original;
workflowUi.drag = null;
renderWorkflowOverview(currentSnapshot);
}
workflowUi.connectingFrom = null;
}
function beginConnection(event) {
if (workflowUi.mode !== 'edit') return;
workflowUi.connectingFrom = event.currentTarget.closest('.workflow-node').dataset.agentId;
event.stopPropagation();
event.preventDefault();
showToast('Выберите входной порт целевого агента', 'info');
}
function finishConnection(event) {
const target = event.currentTarget.closest('.workflow-node').dataset.agentId;
const source = workflowUi.connectingFrom;
workflowUi.connectingFrom = null;
event.stopPropagation();
if (!source || source === target) return;
openEdgeDialog({ id: `edge-${Date.now()}`, source, target, condition: 'SUCCESS', label: '' }, true);
}
function drawWorkflowEdges() {
const canvas = document.getElementById('workflow-canvas');
const svg = document.getElementById('workflow-edges');
const layer = document.getElementById('workflow-edge-layer');
if (!canvas || !svg || !layer) return;
svg.setAttribute('viewBox', `0 0 ${canvas.clientWidth} ${canvas.clientHeight}`);
const parts = [];
workflowUi.draftEdges.forEach((edge) => {
const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`);
const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`);
if (!source || !target) return;
const x1 = (source.offsetLeft + source.offsetWidth) * workflowUi.scale;
const y1 = (source.offsetTop + source.offsetHeight / 2) * workflowUi.scale;
const x2 = target.offsetLeft * workflowUi.scale;
const y2 = (target.offsetTop + target.offsetHeight / 2) * workflowUi.scale;
const bend = Math.max(45, Math.abs(x2 - x1) * .42);
const path = `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
const klass = String(edge.condition || '').toLowerCase();
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2 - 5;
parts.push(`<path class="${wfEscape(klass)}" d="${path}"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path><text class="workflow-edge-label" x="${labelX}" y="${labelY}">${wfEscape(edge.label || edge.condition)}</text>`);
});
layer.innerHTML = parts.join('');
layer.querySelectorAll('.workflow-edge-hit').forEach((path) => path.addEventListener('click', () => {
const edge = workflowUi.draftEdges.find((item) => item.id === path.dataset.edgeId);
if (workflowUi.mode === 'edit' && edge) openEdgeDialog(edge, false);
}));
}
function renderWorkflowMinimap(agents) {
const minimap = document.getElementById('workflow-minimap');
if (!minimap) return;
const maxX = Math.max(900, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 200));
const maxY = Math.max(500, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 100));
minimap.innerHTML = agents.map((agent) => {
const pos = workflowUi.draftPositions[agent.id] || { x: 0, y: 0 };
return `<i style="left:${pos.x / maxX * 92}px;top:${pos.y / maxY * 55}px"></i>`;
}).join('');
}
function setWorkflowScale(value) {
workflowUi.scale = Math.max(.5, Math.min(1.6, Math.round(value * 10) / 10));
document.getElementById('workflow-zoom-value').textContent = `${Math.round(workflowUi.scale * 100)}%`;
renderWorkflowOverview(currentSnapshot);
}
function fitWorkflowGraph() {
const agents = currentSnapshot?.workflow?.agents || [];
const maxX = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 210), 600);
const maxY = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 110), 400);
const canvas = document.getElementById('workflow-canvas');
setWorkflowScale(Math.min(canvas.clientWidth / maxX, canvas.clientHeight / maxY, 1));
}
function markWorkflowDirty() {
workflowUi.dirty = true;
const state = document.getElementById('workflow-save-state');
state.textContent = 'Есть несохранённые изменения';
state.classList.add('dirty');
}
async function saveWorkflowDraft() {
const definition = currentSnapshot?.workflow?.definition || {};
const result = await executeAction('save_workflow', {
id: definition.id,
name: definition.name,
start_agent_id: definition.start_agent_id,
escalation_agent_id: definition.escalation_agent_id,
max_iterations: Number(document.getElementById('workflow-max-iterations').value),
edges: workflowUi.draftEdges,
agents: Object.entries(workflowUi.draftPositions).map(([id, position]) => ({ id, position })),
});
if (result.ok) {
workflowUi.dirty = false;
const state = document.getElementById('workflow-save-state');
state.textContent = 'Сохранено';
state.classList.remove('dirty');
}
}
function renderWorkflowInspector(snapshot, workflow) {
const box = document.getElementById('workflow-inspector');
const agent = (workflow.agents || []).find((item) => item.id === workflowUi.selectedAgentId);
if (!agent) {
box.innerHTML = '<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>';
return;
}
const cfg = agent.execution_config || {};
const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['history', 'История']];
box.innerHTML = `<h2>INSPECTOR: ${wfEscape(agent.name)}</h2><div class="inspector-tabs">${tabs.map(([id, label]) => `<button data-tab="${id}" class="${workflowUi.selectedTab === id ? 'active' : ''}">${label}</button>`).join('')}</div><div id="inspector-tab-content"></div>`;
box.querySelectorAll('.inspector-tabs button').forEach((button) => button.addEventListener('click', () => {
workflowUi.selectedTab = button.dataset.tab;
renderWorkflowInspector(snapshot, workflow);
}));
const content = document.getElementById('inspector-tab-content');
if (workflowUi.selectedTab === 'main') {
content.innerHTML = `<label class="inspector-field">Название<input id="agent-edit-name" value="${wfEscape(agent.name)}"></label><label class="inspector-field">Роль<div class="inspector-value">${wfEscape(agent.role)}</div></label><label class="inspector-field">Описание<textarea id="agent-edit-description">${wfEscape(agent.description)}</textarea></label><label class="inspector-field">Runtime status<div class="inspector-value">${wfEscape(runtimeStateLabel(agent.runtime_state))}</div></label><label class="inspector-field">Текущая задача<div class="inspector-value">${wfEscape(workflow.run?.current_agent_id === agent.id ? workflow.run.current_task : 'Н/Д: агент сейчас не выполняется')}</div></label><div class="inspector-actions"><button class="btn btn-primary btn-sm" id="agent-save-main">Сохранить</button><button class="btn btn-secondary btn-sm" id="agent-delete">Удалить</button></div>`;
document.getElementById('agent-save-main').onclick = () => executeAction('update_agent', { agent_id: agent.id, name: document.getElementById('agent-edit-name').value, description: document.getElementById('agent-edit-description').value });
document.getElementById('agent-delete').onclick = () => deleteWorkflowAgent(agent);
} else if (workflowUi.selectedTab === 'model') {
renderAgentModelTab(content, snapshot, agent);
} else if (workflowUi.selectedTab === 'instructions') {
content.innerHTML = `<div class="agent-file-card"><strong>Agent File</strong><code>${wfEscape(agent.agent_file)}</code><span>${agent.agent_file_exists ? 'Файл существует' : 'Н/Д: файл отсутствует'}</span><div class="inspector-actions"><button class="btn btn-secondary btn-sm" id="agent-file-open">Открыть в редакторе</button></div></div>`;
document.getElementById('agent-file-open').onclick = () => openAgentFileEditor(agent);
} else if (workflowUi.selectedTab === 'tools') {
content.innerHTML = `<label class="inspector-field">Инструменты, через запятую<input id="agent-tools" value="${wfEscape((agent.tools || []).join(', '))}"></label><button class="btn btn-primary btn-sm" id="agent-tools-save">Сохранить</button>`;
document.getElementById('agent-tools-save').onclick = () => executeAction('update_agent', { agent_id: agent.id, tools: document.getElementById('agent-tools').value.split(',').map((v) => v.trim()).filter(Boolean) });
} else if (workflowUi.selectedTab === 'memory') {
content.innerHTML = `<div class="inspector-value">${Object.keys(agent.memory_configuration || {}).length ? `<pre>${wfEscape(JSON.stringify(agent.memory_configuration, null, 2))}</pre>` : 'Н/Д: конфигурация памяти не задана'}</div>`;
} else {
const history = (workflow.events || []).filter((event) => event.agent_id === agent.id);
content.innerHTML = history.length ? history.slice(-20).reverse().map((event) => `<div class="workflow-event ${wfEscape(event.level)}"><time>${wfEscape(formatWorkflowTime(event.timestamp))}</time><span>${wfEscape(event.message)}</span><em>${event.duration_seconds == null ? '' : `${event.duration_seconds} с`}</em></div>`).join('') : '<div class="inspector-value">Н/Д: у агента ещё нет запусков</div>';
}
}
function renderAgentModelTab(content, snapshot, agent) {
const profiles = Object.values(snapshot.all_profiles || {}).filter((profile) => isConnectedProfile(profile));
const cfg = agent.execution_config || {};
const providers = [...new Set(profiles.map((profile) => profile.provider))];
content.innerHTML = `<label class="inspector-field">Провайдер<select id="agent-provider"><option value="">Не назначен</option>${providers.map((provider) => `<option value="${wfEscape(provider)}" ${provider === cfg.provider ? 'selected' : ''}>${wfEscape(provider)}</option>`).join('')}</select></label><label class="inspector-field">Аккаунт<select id="agent-account"></select></label><label class="inspector-field">Модель<select id="agent-model"></select></label><label class="inspector-field">Температура<input id="agent-temperature" type="number" step="0.1" min="0" max="2" value="${cfg.temperature ?? ''}" placeholder="Н/Д: не задана"></label><label class="inspector-field">Макс. токенов<input id="agent-max-tokens" type="number" min="1" value="${cfg.max_tokens ?? ''}" placeholder="Н/Д: не задано"></label><label class="inspector-field">Таймаут, с<input id="agent-timeout" type="number" min="1" value="${cfg.timeout || ''}"></label><button class="btn btn-primary btn-sm" id="agent-model-save">Изменить конфигурацию</button>`;
const providerSelect = document.getElementById('agent-provider');
const accountSelect = document.getElementById('agent-account');
const modelSelect = document.getElementById('agent-model');
const refreshAccounts = () => {
const matches = profiles.filter((profile) => profile.provider === providerSelect.value);
accountSelect.innerHTML = matches.length ? matches.map((profile) => `<option value="${wfEscape(profile.profile_id)}" ${profile.profile_id === cfg.account ? 'selected' : ''}>${wfEscape(profile.account_identity || profile.display_name || profile.profile_id)}</option>`).join('') : '<option value="">Н/Д: нет подключённых аккаунтов</option>';
refreshModels();
};
const refreshModels = () => {
const profile = profiles.find((item) => item.profile_id === accountSelect.value);
const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean);
modelSelect.innerHTML = models.length ? models.map((model) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: модели не обнаружены</option>';
};
providerSelect.onchange = refreshAccounts;
accountSelect.onchange = refreshModels;
refreshAccounts();
document.getElementById('agent-model-save').onclick = () => executeAction('update_agent', {
agent_id: agent.id, provider: providerSelect.value, profile_id: accountSelect.value, model: modelSelect.value,
temperature: nullableNumber('agent-temperature'), max_tokens: nullableNumber('agent-max-tokens'), timeout: nullableNumber('agent-timeout'),
});
}
function nullableNumber(id) {
const value = document.getElementById(id).value.trim();
return value === '' ? null : Number(value);
}
async function deleteWorkflowAgent(agent) {
let result = await executeAction('delete_agent', { agent_id: agent.id });
if (result.ok && result.data?.confirmation_required) {
const refs = result.data.consequences?.workflow_edges || [];
if (!confirm(`Агент участвует в маршруте и/или графе. Будут удалены связи: ${refs.length ? refs.join(', ') : 'нет'}. Продолжить?`)) return;
result = await executeAction('delete_agent', { agent_id: agent.id, force: true });
}
if (result.ok && result.data?.deleted) workflowUi.selectedAgentId = null;
}
function openAgentCreateDialog() {
const profiles = Object.values(currentSnapshot?.all_profiles || {}).filter((profile) => isConnectedProfile(profile));
openWorkflowDialog('Добавить агента', `<label>Название<input id="new-agent-name" required></label><label>Роль (произвольный идентификатор)<input id="new-agent-role" placeholder="security-reviewer"></label><label>Описание<textarea id="new-agent-description"></textarea></label><label>Аккаунт<select id="new-agent-account"><option value="">Не назначать</option>${profiles.map((profile) => `<option value="${wfEscape(profile.profile_id)}">${wfEscape(profile.provider)} · ${wfEscape(profile.account_identity || profile.profile_id)}</option>`).join('')}</select></label><label>Agent File<input id="new-agent-file" placeholder="agents/имя.md"></label>`, async (close) => {
const account = document.getElementById('new-agent-account').value;
const profile = profiles.find((item) => item.profile_id === account);
const result = await executeAction('create_agent', {
name: document.getElementById('new-agent-name').value,
role: document.getElementById('new-agent-role').value,
description: document.getElementById('new-agent-description').value,
account,
model: profile?.preferred_models?.[0] || null,
agent_file: document.getElementById('new-agent-file').value,
});
if (result.ok) close();
});
}
async function openAgentFileEditor(agent) {
const result = await executeAction('read_agent_file', { agent_id: agent.id });
if (!result.ok) return;
const data = result.data || {};
if (!data.exists) {
showToast(`Н/Д: ${data.reason}`, 'warning');
return;
}
openWorkflowDialog(`Agent File — ${agent.name}`, `<label>Путь<input value="${wfEscape(data.path)}" readonly></label><label>Markdown<textarea id="agent-file-content" class="agent-file-editor">${wfEscape(data.content)}</textarea></label><p id="agent-file-unsaved">Нет изменений</p>`, async (close) => {
const saved = await executeAction('save_agent_file', { agent_id: agent.id, content: document.getElementById('agent-file-content').value });
if (saved.ok) close();
}, 'Сохранить');
const editor = document.getElementById('agent-file-content');
editor.addEventListener('input', () => { document.getElementById('agent-file-unsaved').textContent = 'Есть несохранённые изменения'; });
}
function openEdgeDialog(edge, isNew) {
openWorkflowDialog(isNew ? 'Новое ребро' : 'Редактор ребра', `<label>От<input value="${wfEscape(edge.source)}" readonly></label><label>К<input value="${wfEscape(edge.target)}" readonly></label><label>Условие<select id="edge-condition">${['SUCCESS', 'REVIEW_PASSED', 'REVIEW_FAILED', 'NEXT', 'ERROR', 'ALWAYS'].map((condition) => `<option ${condition === edge.condition ? 'selected' : ''}>${condition}</option>`).join('')}</select></label><label>Подпись<input id="edge-label" value="${wfEscape(edge.label || '')}"></label>${isNew ? '' : '<button class="btn btn-secondary btn-sm" id="edge-delete">Удалить ребро</button>'}`, (close) => {
const updated = { ...edge, condition: document.getElementById('edge-condition').value, label: document.getElementById('edge-label').value };
const index = workflowUi.draftEdges.findIndex((item) => item.id === edge.id);
if (index >= 0) workflowUi.draftEdges[index] = updated; else workflowUi.draftEdges.push(updated);
markWorkflowDirty(); close(); drawWorkflowEdges();
});
if (!isNew) document.getElementById('edge-delete').onclick = () => {
workflowUi.draftEdges = workflowUi.draftEdges.filter((item) => item.id !== edge.id);
markWorkflowDirty(); document.querySelector('.workflow-dialog-backdrop')?.remove(); drawWorkflowEdges();
};
}
function openWorkflowDialog(title, body, onSave, saveLabel = 'Применить') {
document.querySelector('.workflow-dialog-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'workflow-dialog-backdrop';
backdrop.innerHTML = `<div class="workflow-dialog"><h2>${wfEscape(title)}</h2>${body}<div class="workflow-dialog-actions"><button class="btn btn-secondary" data-dialog-cancel>Отмена</button><button class="btn btn-primary" data-dialog-save>${wfEscape(saveLabel)}</button></div></div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector('[data-dialog-cancel]').onclick = close;
backdrop.querySelector('[data-dialog-save]').onclick = () => onSave(close);
backdrop.addEventListener('click', (event) => { if (event.target === backdrop) close(); });
}
async function startWorkflowRun() {
if (workflowUi.dirty) {
showToast('Сначала сохраните изменения графа', 'warning');
return;
}
const task = document.getElementById('workflow-task').value.trim();
await executeAction('start_workflow', { task });
setWorkflowMode('live');
}
function renderWorkflowEvents(events) {
const box = document.getElementById('workflow-events-list');
if (!events.length) {
box.innerHTML = '<p>Н/Д: workflow ещё не создавал событий</p>';
return;
}
box.innerHTML = events.slice(-20).reverse().map((event) => `<div class="workflow-event ${wfEscape(event.level)}"><time>${wfEscape(formatWorkflowTime(event.timestamp))}</time><span>${wfEscape(event.message)}${event.error ? `${wfEscape(event.error)}` : ''}</span><em>${wfEscape(event.type)}</em></div>`).join('');
}
function formatWorkflowTime(value) {
if (!value) return 'Н/Д';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString('ru-RU');
}

View file

@ -0,0 +1,646 @@
"""Persistent agents, workflow graph and live execution for Hermes Hub.
The router role registry remains the source of truth for logical agents and
Provider -> Account -> Model assignment. This module adds the pieces that do
not fit the routing schema: Agent Files, editor layout, workflow transitions,
execution checkpoints and a bounded event journal.
"""
from __future__ import annotations
import json
import re
import threading
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Optional
from antigravity_provider import paths
from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config
AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"}
EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"}
def _utc_timestamp() -> str:
import datetime
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _slug(value: str) -> str:
result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
return result or f"agent-{uuid.uuid4().hex[:8]}"
def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]:
"""Resolve an Agent File below HERMES_HOME/agents and reject traversal."""
root = paths.get_agent_files_dir().resolve()
candidate_name = Path(value or f"{agent_id}.md").name
if not candidate_name.lower().endswith(".md"):
candidate_name += ".md"
target = (root / candidate_name).resolve()
if target.parent != root:
raise ValueError("Agent File должен находиться в каталоге agents")
return target, f"agents/{candidate_name}"
@dataclass
class AgentDefinition:
id: str
name: str
role: str
description: str = ""
agent_file: str = ""
tools: list[str] = field(default_factory=list)
memory_configuration: dict[str, Any] = field(default_factory=dict)
execution_policy: dict[str, Any] = field(default_factory=dict)
timeout: int = 180
temperature: Optional[float] = None
max_tokens: Optional[int] = None
position: dict[str, float] = field(default_factory=lambda: {"x": 80.0, "y": 80.0})
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class WorkflowEdge:
id: str
source: str
target: str
condition: str = "SUCCESS"
label: str = ""
@dataclass
class WorkflowDefinition:
id: str = "main"
name: str = "Основной workflow"
edges: list[WorkflowEdge] = field(default_factory=list)
max_iterations: int = 5
escalation_agent_id: Optional[str] = None
start_agent_id: Optional[str] = None
@dataclass
class WorkflowEvent:
timestamp: str
type: str
message: str
level: str = "info"
run_id: Optional[str] = None
agent_id: Optional[str] = None
iteration: Optional[int] = None
provider: Optional[str] = None
account: Optional[str] = None
model: Optional[str] = None
duration_seconds: Optional[float] = None
error: Optional[str] = None
class WorkflowService:
_instance: Optional["WorkflowService"] = None
_instance_lock = threading.Lock()
def __init__(self, state_path: Optional[Path] = None) -> None:
self.state_path = state_path or paths.get_workflow_state_path()
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self.agents: dict[str, AgentDefinition] = {}
self.workflow = WorkflowDefinition()
self.events: list[WorkflowEvent] = []
self.run: dict[str, Any] = self._idle_run()
self._load()
self._migrate_router_roles()
@classmethod
def get(cls) -> "WorkflowService":
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
@staticmethod
def _idle_run() -> dict[str, Any]:
return {
"id": None,
"status": "idle",
"current_agent_id": None,
"current_task": None,
"iteration": 0,
"started_at": None,
"finished_at": None,
"elapsed_seconds": None,
"last_result": None,
"error": None,
"agent_states": {},
}
def _load(self) -> None:
if not self.state_path.is_file():
return
try:
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
self.agents = {
item["id"]: AgentDefinition(**item)
for item in raw.get("agents", [])
if isinstance(item, dict) and item.get("id")
}
wf = raw.get("workflow") or {}
edges = [WorkflowEdge(**edge) for edge in wf.pop("edges", []) if isinstance(edge, dict)]
self.workflow = WorkflowDefinition(edges=edges, **wf)
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
self.run = raw.get("run") or self._idle_run()
if self.run.get("status") in {"running", "stopping"}:
self.run["status"] = "interrupted"
self.run["error"] = "Выполнение прервано перезапуском Hermes Hub; checkpoint сохранён"
self._event("WORKFLOW_INTERRUPTED", self.run["error"], level="warning")
except (OSError, ValueError, TypeError):
self.agents = {}
self.workflow = WorkflowDefinition()
self.events = []
self.run = self._idle_run()
def _save(self) -> None:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema_version": 1,
"agents": [asdict(agent) for agent in self.agents.values()],
"workflow": asdict(self.workflow),
"events": [asdict(event) for event in self.events[-200:]],
"run": self.run,
}
temp = self.state_path.with_suffix(".tmp")
temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(self.state_path)
def _migrate_router_roles(self) -> None:
config = load_router_config()
changed = False
for index, (role_id, policy) in enumerate(config.roles.items()):
if role_id in self.agents:
continue
target, relative = _safe_agent_file("", role_id)
name = role_id.replace("-", " ").title()
description = ""
try:
from antigravity_provider.router.role_registry import get_role_definition
definition = get_role_definition(role_id)
name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name
description = getattr(definition, "description", "")
except (ImportError, AttributeError, TypeError):
pass
agent = AgentDefinition(
id=role_id,
name=name,
role=role_id,
description=description,
agent_file=relative,
position={"x": 70.0 + (index % 2) * 280.0, "y": 45.0 + (index // 2) * 125.0},
)
self.agents[role_id] = agent
self._ensure_file(target, agent)
changed = True
if changed:
self._save()
@staticmethod
def _ensure_file(target: Path, agent: AgentDefinition) -> None:
if not target.exists():
body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n"
target.write_text(body, encoding="utf-8")
def _execution_config(self, agent: AgentDefinition) -> dict[str, Any]:
config = load_router_config()
policy = config.roles.get(agent.role)
profile_id = policy.preferred_chain[0] if policy and policy.preferred_chain else None
profile = config.profiles.get(profile_id) if profile_id else None
model = policy.default_model if policy else None
if not model and profile and profile.preferred_models:
model = profile.preferred_models[0]
return {
"provider": profile.provider if profile else None,
"account": profile_id,
"model": model,
"timeout": agent.timeout,
"temperature": agent.temperature,
"max_tokens": agent.max_tokens,
"unavailable_reason": None if profile else "Для роли не назначен доступный аккаунт",
}
def snapshot(self) -> dict[str, Any]:
with self._lock:
agents = []
for agent in self.agents.values():
item = asdict(agent)
target, relative = _safe_agent_file(agent.agent_file, agent.id)
item["agent_file"] = relative
item["agent_file_exists"] = target.is_file()
item["execution_config"] = self._execution_config(agent)
item["runtime_state"] = (self.run.get("agent_states") or {}).get(agent.id, "waiting")
agents.append(item)
return {
"agents": agents,
"definition": asdict(self.workflow),
"run": dict(self.run),
"events": [asdict(event) for event in self.events[-60:]],
"is_loading": False,
}
def read_agent_file(self, agent_id: str) -> dict[str, Any]:
with self._lock:
agent = self._require_agent(agent_id)
target, relative = _safe_agent_file(agent.agent_file, agent.id)
if not target.is_file():
return {"path": relative, "exists": False, "content": None, "reason": "Файл не найден на диске"}
return {"path": relative, "exists": True, "content": target.read_text(encoding="utf-8")}
def save_agent_file(self, agent_id: str, content: str) -> dict[str, Any]:
with self._lock:
agent = self._require_agent(agent_id)
target, relative = _safe_agent_file(agent.agent_file, agent.id)
temporary = target.with_suffix(".md.tmp")
temporary.write_text(str(content), encoding="utf-8")
temporary.replace(target)
self._event("AGENT_FILE_SAVED", f"Сохранён Agent File {relative}", agent_id=agent_id)
self._save()
return {"path": relative, "exists": True}
def create_agent(self, data: dict[str, Any]) -> AgentDefinition:
name = str(data.get("name") or "").strip()
role = _slug(str(data.get("role") or name))
agent_id = _slug(str(data.get("id") or role))
if not name:
raise ValueError("Укажите название агента")
with self._lock:
if agent_id in self.agents:
raise ValueError("Агент с таким идентификатором уже существует")
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
config = load_router_config()
if profile_id and profile_id not in config.profiles:
raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе")
config.roles[role] = RolePolicy(
role_name=role,
preferred_chain=[profile_id] if profile_id else [],
fallback_capabilities=list(data.get("fallback_capabilities") or [role]),
default_model=data.get("model") or None,
)
if not save_router_config(config):
raise OSError("Не удалось сохранить назначение агента")
target, relative = _safe_agent_file(str(data.get("agent_file") or ""), agent_id)
agent = AgentDefinition(
id=agent_id,
name=name,
role=role,
description=str(data.get("description") or ""),
agent_file=relative,
tools=[str(item) for item in data.get("tools", [])],
memory_configuration=dict(data.get("memory_configuration") or {}),
execution_policy=dict(data.get("execution_policy") or {}),
timeout=max(1, int(data.get("timeout") or 180)),
temperature=float(data["temperature"]) if data.get("temperature") is not None else None,
max_tokens=int(data["max_tokens"]) if data.get("max_tokens") is not None else None,
position=dict(data.get("position") or {"x": 80.0, "y": 80.0}),
)
self.agents[agent_id] = agent
copy_from = data.get("copy_from")
if copy_from:
source = self.read_agent_file(str(copy_from))
target.write_text(source.get("content") or "", encoding="utf-8")
else:
self._ensure_file(target, agent)
self._event("AGENT_CREATED", f"Создан агент «{name}»", agent_id=agent_id)
self._save()
return agent
def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition:
with self._lock:
agent = self._require_agent(agent_id)
config = load_router_config()
policy = config.roles.get(agent.role)
if not policy:
policy = RolePolicy(role_name=agent.role)
config.roles[agent.role] = policy
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
if profile_id:
profile = config.profiles.get(profile_id)
if not profile:
raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе")
requested_provider = str(data.get("provider") or "").strip()
if requested_provider and profile.provider != requested_provider:
raise ValueError("Аккаунт не принадлежит выбранному провайдеру")
policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id]
if "model" in data:
model = str(data.get("model") or "").strip() or None
if model and profile_id:
profile = config.profiles[profile_id]
if model not in profile.preferred_models:
raise ValueError("Модель не доступна выбранному аккаунту")
policy.default_model = model
if not save_router_config(config):
raise OSError("Не удалось сохранить назначение агента")
for attr in ("name", "description"):
if attr in data:
setattr(agent, attr, str(data[attr]).strip())
for attr in ("tools", "memory_configuration", "execution_policy", "position"):
if attr in data:
setattr(agent, attr, type(getattr(agent, attr))(data[attr]))
for attr in ("timeout", "max_tokens"):
if attr in data and data[attr] is not None:
setattr(agent, attr, int(data[attr]))
if "temperature" in data:
agent.temperature = float(data["temperature"]) if data["temperature"] is not None else None
self._event("AGENT_UPDATED", f"Обновлён агент «{agent.name}»", agent_id=agent_id)
self._save()
return agent
def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]:
with self._lock:
agent = self._require_agent(agent_id)
edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id]
route_used = bool(load_router_config().roles.get(agent.role))
consequences = {"workflow_edges": edge_ids, "routing_role": agent.role if route_used else None}
if (edge_ids or route_used) and not force:
return {"deleted": False, "confirmation_required": True, "consequences": consequences}
config = load_router_config()
config.roles.pop(agent.role, None)
if not save_router_config(config):
raise OSError("Не удалось удалить роль из маршрутизатора")
self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids]
self.agents.pop(agent_id)
self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning")
self._save()
return {"deleted": True, "consequences": consequences}
def save_workflow(self, data: dict[str, Any]) -> WorkflowDefinition:
with self._lock:
if self.run.get("status") in {"running", "stopping"}:
raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения")
edges: list[WorkflowEdge] = []
seen: set[str] = set()
for raw in data.get("edges", []):
source, target = str(raw.get("source") or ""), str(raw.get("target") or "")
condition = str(raw.get("condition") or "SUCCESS").upper()
if source not in self.agents or target not in self.agents:
raise ValueError("Ребро ссылается на отсутствующего агента")
if condition not in EDGE_CONDITIONS:
raise ValueError(f"Неизвестное условие перехода: {condition}")
edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}")
if edge_id in seen:
raise ValueError("Идентификаторы рёбер должны быть уникальны")
seen.add(edge_id)
edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or "")))
max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations)
if not 1 <= max_iterations <= 100:
raise ValueError("Предел итераций должен быть от 1 до 100")
for raw_agent in data.get("agents", []):
agent = self.agents.get(str(raw_agent.get("id") or ""))
pos = raw_agent.get("position")
if agent and isinstance(pos, dict):
agent.position = {"x": float(pos.get("x", 0)), "y": float(pos.get("y", 0))}
self.workflow = WorkflowDefinition(
id=str(data.get("id") or self.workflow.id),
name=str(data.get("name") or self.workflow.name),
edges=edges,
max_iterations=max_iterations,
escalation_agent_id=data.get("escalation_agent_id") or None,
start_agent_id=data.get("start_agent_id") or None,
)
self._event("WORKFLOW_SAVED", f"Сохранён workflow «{self.workflow.name}»")
self._save()
return self.workflow
def start(self, task: str) -> dict[str, Any]:
task = str(task or "").strip()
if not task:
raise ValueError("Для запуска укажите реальную задачу")
with self._lock:
if self._thread and self._thread.is_alive():
raise ValueError("Workflow уже выполняется")
start_id = self.workflow.start_agent_id or (next(iter(self.agents), None))
if not start_id or start_id not in self.agents:
raise ValueError("В workflow нет стартового агента")
self._stop.clear()
self.run = self._idle_run()
self.run.update({
"id": uuid.uuid4().hex,
"status": "running",
"current_agent_id": start_id,
"current_task": task,
"iteration": 1,
"started_at": _utc_timestamp(),
})
self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1)
self._save()
self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True)
self._thread.start()
return dict(self.run)
def stop(self) -> dict[str, Any]:
with self._lock:
if self.run.get("status") != "running":
raise ValueError("Нет выполняющегося workflow")
self.run["status"] = "stopping"
self._stop.set()
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
self._save()
return dict(self.run)
def _execute(self) -> None:
from antigravity_provider.router.router_engine import get_router_engine
started = time.monotonic()
context = str(self.run.get("current_task") or "")
current = str(self.run.get("current_agent_id") or "")
visited: dict[str, int] = {}
try:
engine = get_router_engine()
engine.reload_config()
while current and not self._stop.is_set():
with self._lock:
agent = self._require_agent(current)
visited[current] = visited.get(current, 0) + 1
iteration = max(visited.values())
self.run.update({"current_agent_id": current, "iteration": iteration})
if iteration > self.workflow.max_iterations:
message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
self.run.update({"status": "failed", "error": message})
self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration)
break
file_data = self.read_agent_file(current)
if not file_data["exists"]:
raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}")
self.run.setdefault("agent_states", {})[current] = (
"reviewing" if "review" in agent.role.lower() else "working"
)
self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration)
self._save()
request = {
"model": self._execution_config(agent).get("model"),
"messages": [
{"role": "system", "content": file_data["content"]},
{"role": "user", "content": context},
],
"timeout": agent.timeout,
"metadata": {"role": agent.role, "workflow_run_id": self.run["id"]},
}
if agent.temperature is not None:
request["temperature"] = agent.temperature
if agent.max_tokens is not None:
request["max_tokens"] = agent.max_tokens
step_started = time.monotonic()
response = engine.route_request(request, role=agent.role, session_id=self.run["id"])
duration = round(time.monotonic() - step_started, 3)
text = self._response_text(response)
status = self._result_status(response, text)
metadata = response.get("router_metadata", {}) if isinstance(response, dict) else {}
with self._lock:
self.run["last_result"] = {"status": status, "content": text, "router_metadata": metadata}
self.run.setdefault("agent_states", {})[current] = (
"error" if status in {"ERROR", "REVIEW_FAILED"} else "completed"
)
self._event(
"AGENT_COMPLETED",
f"{agent.name}: {status}",
level="success" if status not in {"ERROR", "REVIEW_FAILED"} else "warning",
agent_id=current,
iteration=iteration,
provider=metadata.get("provider"),
account=metadata.get("profile_id"),
model=metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"),
duration_seconds=duration,
error=text if status == "ERROR" else None,
)
edge = next(
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
None,
)
if not edge and status not in {"ERROR", "REVIEW_FAILED"}:
edge = next(
(item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}),
None,
)
if not edge:
self.run["status"] = "failed" if status in {"ERROR", "REVIEW_FAILED"} else "completed"
if status == "ERROR":
self.run["error"] = text or "Провайдер вернул ERROR без текста"
self._event(
"WORKFLOW_FAILED" if self.run["status"] == "failed" else "WORKFLOW_COMPLETED",
f"Workflow завершён со статусом {status}",
level="error" if self.run["status"] == "failed" else "success",
agent_id=current,
iteration=iteration,
error=self.run.get("error") if self.run["status"] == "failed" else None,
)
if status == "ERROR":
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
"workflow",
f"Ошибка агента «{agent.name}»",
details=self.run["error"],
level="error",
)
except Exception:
pass
break
self._event(
"WORKFLOW_TRANSITION",
f"Переход {current}{edge.target}: {edge.condition}",
agent_id=current,
iteration=iteration,
)
context = json.dumps({
"original_task": self.run["current_task"],
"previous_agent": current,
"structured_result": {"status": status, "content": text},
}, ensure_ascii=False)
current = edge.target
self._save()
with self._lock:
if self._stop.is_set():
self.run.update({"status": "stopped", "error": "Остановлено пользователем"})
self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning")
except Exception as exc:
with self._lock:
self.run.update({"status": "failed", "error": str(exc)})
self._event("PROVIDER_ERROR", "Ошибка выполнения workflow", level="error", agent_id=current, error=str(exc))
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error")
except Exception:
pass
finally:
with self._lock:
self.run["finished_at"] = _utc_timestamp()
self.run["elapsed_seconds"] = round(time.monotonic() - started, 3)
self.run["current_agent_id"] = current or self.run.get("current_agent_id")
self._save()
@staticmethod
def _response_text(response: Any) -> str:
if not isinstance(response, dict):
return str(response)
choices = response.get("choices")
if isinstance(choices, list) and choices:
message = choices[0].get("message", {})
return str(message.get("content") or choices[0].get("text") or "")
return str(response.get("content") or response.get("text") or response.get("output") or "")
@staticmethod
def _result_status(response: Any, text: str) -> str:
if isinstance(response, dict):
explicit = response.get("status") or response.get("structured_status")
if explicit and str(explicit).upper() in EDGE_CONDITIONS:
return str(explicit).upper()
for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"):
if re.search(rf"\b{status}\b", text.upper()):
return status
return "SUCCESS"
def _event(self, event_type: str, message: str, **kwargs: Any) -> None:
self.events.append(WorkflowEvent(_utc_timestamp(), event_type, message, **kwargs))
self.events = self.events[-200:]
def _require_agent(self, agent_id: str) -> AgentDefinition:
agent = self.agents.get(agent_id)
if not agent:
raise ValueError("Агент не найден")
return agent
def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]:
"""Execute a workflow action through the shared ActionExecutor layer."""
service = WorkflowService.get()
if action == "create_agent":
result = asdict(service.create_agent(data))
elif action == "update_agent":
result = asdict(service.update_agent(str(data.get("agent_id") or ""), data))
elif action == "delete_agent":
result = service.delete_agent(str(data.get("agent_id") or ""), bool(data.get("force")))
elif action == "read_agent_file":
result = service.read_agent_file(str(data.get("agent_id") or ""))
elif action == "save_agent_file":
result = service.save_agent_file(str(data.get("agent_id") or ""), str(data.get("content") or ""))
elif action == "save_workflow":
result = asdict(service.save_workflow(data))
elif action == "start_workflow":
result = service.start(str(data.get("task") or ""))
elif action == "stop_workflow":
result = service.stop()
else:
raise ValueError("Неизвестное действие workflow")
if action in {"create_agent", "update_agent", "delete_agent"}:
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=False)
except Exception:
pass
return {"ok": True, "message": "Выполнено", "data": result}

View file

@ -130,7 +130,7 @@ def test_web_client_html_and_js_7_views_parity():
# Overview view elements
assert "renderOverviewView" in app_js
assert "overview-route-diagram" in index_html
assert "workflow-canvas" in index_html
# Analytics view elements
assert "analytics-total-calls" in index_html

View file

@ -0,0 +1,163 @@
from __future__ import annotations
import json
import pytest
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
save_router_config,
)
from antigravity_provider.router.workflow_service import WorkflowService
@pytest.fixture
def workflow_service(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = RouterConfig(
profiles={
"account-a": RouterProfileConfig(
profile_id="account-a",
provider="openai-codex",
preferred_models=["model-real-from-config"],
)
},
roles={"developer": RolePolicy(role_name="developer", preferred_chain=["account-a"])},
)
save_router_config(config)
return WorkflowService(tmp_path / "workflow_state.json")
def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path):
snapshot = workflow_service.snapshot()
assert "developer" in [agent["id"] for agent in snapshot["agents"]]
agent = next(item for item in snapshot["agents"] if item["id"] == "developer")
assert agent["agent_file"] == "agents/developer.md"
assert agent["agent_file_exists"] is True
assert (tmp_path / "agents" / "developer.md").is_file()
assert agent["execution_config"]["account"] == "account-a"
assert agent["execution_config"]["model"] == "model-real-from-config"
def test_create_update_file_and_restart_persistence(workflow_service, tmp_path):
created = workflow_service.create_agent({
"name": "Security Reviewer",
"role": "security-reviewer",
"account": "account-a",
"model": "model-real-from-config",
"description": "Проверяет безопасность",
})
workflow_service.save_agent_file(created.id, "# Security\n\nOnly measured facts.")
workflow_service.update_agent(created.id, {"timeout": 91, "position": {"x": 33, "y": 44}})
restarted = WorkflowService(tmp_path / "workflow_state.json")
item = next(agent for agent in restarted.snapshot()["agents"] if agent["id"] == created.id)
assert item["timeout"] == 91
assert item["position"] == {"x": 33, "y": 44}
assert restarted.read_agent_file(created.id)["content"].endswith("Only measured facts.")
def test_delete_requires_explicit_confirmation_when_referenced(workflow_service):
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
workflow_service.save_workflow({
"start_agent_id": "developer",
"max_iterations": 3,
"edges": [{"id": "review", "source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}],
})
warning = workflow_service.delete_agent("test-reviewer")
assert warning["confirmation_required"] is True
assert warning["consequences"]["workflow_edges"] == ["review"]
result = workflow_service.delete_agent("test-reviewer", force=True)
assert result["deleted"] is True
assert workflow_service.workflow.edges == []
def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service):
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
definition = workflow_service.save_workflow({
"start_agent_id": "developer",
"max_iterations": 2,
"edges": [
{"source": "developer", "target": "test-reviewer", "condition": "SUCCESS"},
{"source": "test-reviewer", "target": "developer", "condition": "REVIEW_FAILED"},
],
})
assert definition.max_iterations == 2
assert definition.edges[1].condition == "REVIEW_FAILED"
payload = json.loads(workflow_service.state_path.read_text(encoding="utf-8"))
assert payload["workflow"]["max_iterations"] == 2
def test_invalid_edge_and_unknown_model_are_rejected(workflow_service):
with pytest.raises(ValueError, match="отсутствующего агента"):
workflow_service.save_workflow({"edges": [{"source": "developer", "target": "missing"}]})
with pytest.raises(ValueError, match="не доступна"):
workflow_service.update_agent("developer", {"account": "account-a", "model": "invented-model"})
def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path):
workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "developer"})
workflow_service._save()
restarted = WorkflowService(tmp_path / "workflow_state.json")
assert restarted.run["status"] == "interrupted"
assert "перезапуском" in restarted.run["error"]
assert restarted.events[-1].type == "WORKFLOW_INTERRUPTED"
def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch):
workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"})
workflow_service.save_workflow({
"start_agent_id": "developer",
"max_iterations": 2,
"edges": [
{"source": "developer", "target": "loop-reviewer", "condition": "SUCCESS"},
{"source": "loop-reviewer", "target": "developer", "condition": "REVIEW_FAILED"},
],
})
class FakeEngine:
def reload_config(self):
return None
def route_request(self, request, role=None, session_id=None):
status = "REVIEW_FAILED" if role == "loop-reviewer" else "SUCCESS"
return {
"choices": [{"message": {"content": status}}],
"router_metadata": {
"provider": "measured-provider",
"profile_id": "account-a",
"selection_trace": {"selected_model": "model-real-from-config"},
},
}
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine())
workflow_service.start("Проверить реальный цикл")
workflow_service._thread.join(timeout=3)
assert workflow_service.run["status"] == "failed"
assert workflow_service.run["error"] == "Достигнут предел итераций: 2"
assert any(event.type == "WORKFLOW_MAX_ITERATIONS" for event in workflow_service.events)
def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatch):
provider_text = "Provider Error: authentication token missing for account-a"
class ErrorEngine:
def reload_config(self):
return None
def route_request(self, request, role=None, session_id=None):
return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]}
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine())
workflow_service.workflow.start_agent_id = "developer"
workflow_service.workflow.edges = []
workflow_service.start("Проверить ошибку")
workflow_service._thread.join(timeout=3)
assert workflow_service.run["status"] == "failed"
assert provider_text in workflow_service.run["error"]
assert any(provider_text in (event.error or "") for event in workflow_service.events)
assert workflow_service.snapshot()["agents"][0]["runtime_state"] == "error"