merge: A30 — workflow canvas, agent files, live workspace
This commit is contained in:
commit
f5d4826e7d
17 changed files with 1587 additions and 42 deletions
43
agents/inbox/2026-08-26-antigravity-release-gate-a30.md
Normal file
43
agents/inbox/2026-08-26-antigravity-release-gate-a30.md
Normal 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`;
|
||||
- список найденных дефектов с приоритетом P0–P3;
|
||||
- скриншоты LIVE, EDIT, Inspector, Agent File и редактора ребра;
|
||||
- отдельный список пропущенных проверок.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Ничего не исправлять молча в чужой ветке: найденные дефекты оформить отдельным патчем/коммитом или вернуть владельцу.
|
||||
- Не удалять пользовательские изменения в установщике, бинарниках и заданиях inbox.
|
||||
- Не объявлять release-ready при известных блокерах.
|
||||
BIN
docs/screenshots/a30/agent-file-editor.png
Normal file
BIN
docs/screenshots/a30/agent-file-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/screenshots/a30/edge-editor.png
Normal file
BIN
docs/screenshots/a30/edge-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/screenshots/a30/overview-edit-inspector.png
Normal file
BIN
docs/screenshots/a30/overview-edit-inspector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/screenshots/a30/overview-live-provider-error.png
Normal file
BIN
docs/screenshots/a30/overview-live-provider-error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/screenshots/a30/overview-live.png
Normal file
BIN
docs/screenshots/a30/overview-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 был готов давно, но
|
||||
# наружу не выведен: веб-мастер показывал заглушку «не реализовано», и
|
||||
# подключить эти провайдеры можно было только из десктопа. Настоящие
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
89
src/antigravity_provider/router/web/static/workflow.css
Normal file
89
src/antigravity_provider/router/web/static/workflow.css
Normal 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; } }
|
||||
497
src/antigravity_provider/router/web/static/workflow.js
Normal file
497
src/antigravity_provider/router/web/static/workflow.js
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||
})[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');
|
||||
}
|
||||
646
src/antigravity_provider/router/workflow_service.py
Normal file
646
src/antigravity_provider/router/workflow_service.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
163
tests/test_workflow_service_a30.py
Normal file
163
tests/test_workflow_service_a30.py
Normal 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"
|
||||
Loading…
Reference in a new issue