From ecd1f20d59783904e27543cac724244b88c3912c Mon Sep 17 00:00:00 2001 From: Hermes VM Date: Sat, 30 May 2026 07:43:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20Phase=202=20=E2=80=94=20inst?= =?UTF-8?q?ance=20dimension=20across=20Mission=20Control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Phase 2. Every entity in `web/src/lib/hermes` now carries an `instanceId: 'vijay' | 'bheem'` (with `'all'` allowed for cross-cutting agents like Hermes Core / GitHub link), and a global instance switcher above every Mission Control pane filters them. Library changes (`web/src/lib/hermes.ts`): - New `HermesInstanceId` / `HermesInstanceFilter` types + `HERMES_INSTANCES` metadata array. - `instanceId` added to `HermesProduct`, `HermesTask`, `HermesEvent`, `HermesRun`, `HermesAgentStatus`. Seed data deterministically split ~50/50 across instances; agents tagged per-scope (Local VM runner → bheem, CLI runner / Scheduler → vijay, Hermes Core / GitHub / OpenClaw / deployment / notifications → all). - `getHermesTasks({instance})`, `getHermesProducts(view, instance)`, `getHermesAgents(instance)`, `getHermesHistory(instance)`, `getHermesOverview(instance)` all accept the filter; helper `instanceMatches(scope, filter)` keeps the semantics consistent (always-match for `'all'` on either side). UI changes: - New `HermesInstanceProvider` (React context, localStorage-backed under `hermes.instanceFilter.v1`, SSR-safe default to avoid hydration mismatch) mounted in `app/hermes/layout.tsx`. - New `HermesInstanceSwitcher` segmented control (radiogroup with aria-checked) rendered in the layout header above every pane. - New `HermesInstanceBadge` shown on task rows (Active Missions + Task Ledger), product cards (overview minicards + portfolio cards), and agent cards. - `/hermes` overview gains a "Per-instance roll-up" section that always shows Vijay vs Bheem side-by-side regardless of the active filter — that's the always-cross-instance comparison view, while the eight metric cards above it are filtered by the switcher. Tests: - 2 new unit tests in `lib/hermes.test.ts` (instance tagging on seed data + filter semantics across tasks/products/agents/overview). - 1 new E2E test asserting the switcher's radiogroup, default selection, and persistence-friendly state change. - All green: 13/13 web unit tests, 7/7 E2E. `web/test-results/` and `web/playwright-report/` added to `.gitignore` since they're regenerated per run. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dashboard/web/.gitignore | 2 + dashboard/web/e2e/hermes.spec.ts | 18 +++ dashboard/web/src/app/hermes/agents/page.tsx | 11 +- dashboard/web/src/app/hermes/history/page.tsx | 14 +- dashboard/web/src/app/hermes/layout.tsx | 24 ++- dashboard/web/src/app/hermes/page.tsx | 88 +++++++++-- .../web/src/app/hermes/products/page.tsx | 12 +- dashboard/web/src/app/hermes/tasks/page.tsx | 15 +- .../components/hermes-instance-switcher.tsx | 67 +++++++++ .../web/src/lib/hermes-instance-context.tsx | 70 +++++++++ dashboard/web/src/lib/hermes.test.ts | 34 +++++ dashboard/web/src/lib/hermes.ts | 137 +++++++++++++++--- docs/hermes_dashboard_v2_roadmap.md | 10 +- 13 files changed, 447 insertions(+), 55 deletions(-) create mode 100644 dashboard/web/src/components/hermes-instance-switcher.tsx create mode 100644 dashboard/web/src/lib/hermes-instance-context.tsx diff --git a/dashboard/web/.gitignore b/dashboard/web/.gitignore index 98e05a0..4b003ac 100644 --- a/dashboard/web/.gitignore +++ b/dashboard/web/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/test-results +/playwright-report # next.js /.next/ diff --git a/dashboard/web/e2e/hermes.spec.ts b/dashboard/web/e2e/hermes.spec.ts index c646476..1f13b9a 100644 --- a/dashboard/web/e2e/hermes.spec.ts +++ b/dashboard/web/e2e/hermes.spec.ts @@ -100,4 +100,22 @@ test.describe('Hermes Mission Control', () => { await expect(page.getByRole('heading', { name: 'Hermes learning' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible(); }); + + test('exposes a global instance switcher with All / Vijay / Bheem', async ({ page }) => { + await page.goto('/hermes'); + const switcher = page.getByRole('radiogroup', { name: 'Hermes instance filter' }); + await expect(switcher).toBeVisible(); + + // Default selection persists across navigation; "All" should be the + // active option on first visit (no localStorage entry yet). + await expect(switcher.getByRole('radio', { name: 'All' })).toHaveAttribute('aria-checked', 'true'); + + // Per-instance roll-up section should always be visible regardless of filter. + await expect(page.getByRole('heading', { name: 'Per-instance roll-up' })).toBeVisible(); + + // Switching to Bheem should still keep the page rendering correctly. + await switcher.getByRole('radio', { name: 'Bheem (uma)' }).click(); + await expect(switcher.getByRole('radio', { name: 'Bheem (uma)' })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible(); + }); }); diff --git a/dashboard/web/src/app/hermes/agents/page.tsx b/dashboard/web/src/app/hermes/agents/page.tsx index 53b808c..e332062 100644 --- a/dashboard/web/src/app/hermes/agents/page.tsx +++ b/dashboard/web/src/app/hermes/agents/page.tsx @@ -3,11 +3,15 @@ import Link from 'next/link'; import { ArrowLeft, Gauge, ShieldAlert, ServerCog } from 'lucide-react'; import { Badge, Button } from '@/components/ui/Primitives'; +import { useMemo } from 'react'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; import { getHermesAgents } from '@/lib/hermes'; export default function HermesAgentsPage() { - const agents = getHermesAgents(); + const { selectedInstance } = useHermesInstance(); + const agents = useMemo(() => getHermesAgents(selectedInstance), [selectedInstance]); const healthy = agents.filter((agent) => agent.status === 'healthy').length; const degraded = agents.filter((agent) => agent.status === 'degraded').length; const offline = agents.filter((agent) => agent.status === 'offline').length; @@ -33,7 +37,10 @@ export default function HermesAgentsPage() {

{agent.name}

{agent.type} · {agent.callsToday} calls today

- {agent.status} +
+ {agent.status} + +
Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}
diff --git a/dashboard/web/src/app/hermes/history/page.tsx b/dashboard/web/src/app/hermes/history/page.tsx index 9184322..2a78d0d 100644 --- a/dashboard/web/src/app/hermes/history/page.tsx +++ b/dashboard/web/src/app/hermes/history/page.tsx @@ -3,20 +3,28 @@ import Link from 'next/link'; import { ArrowLeft, Clock3, Flame, TrendingDown, TrendingUp } from 'lucide-react'; import { Badge, Button } from '@/components/ui/Primitives'; +import { useMemo } from 'react'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; import { getHermesHistory, hermesTasks } from '@/lib/hermes'; export default function HermesHistoryPage() { - const history = getHermesHistory(); + const { selectedInstance } = useHermesInstance(); + const history = useMemo(() => getHermesHistory(selectedInstance), [selectedInstance]); + const filteredTasks = useMemo( + () => (selectedInstance === 'all' ? hermesTasks : hermesTasks.filter((task) => task.instanceId === selectedInstance)), + [selectedInstance], + ); const completedTrend = history.map((point) => point.completed); const failedTrend = history.map((point) => point.failed); const maxValue = Math.max(...history.flatMap((point) => [point.completed, point.failed, point.blocked, point.active]), 1); const weeklyCompleted = completedTrend.reduce((sum, value) => sum + value, 0); const weeklyFailed = failedTrend.reduce((sum, value) => sum + value, 0); const blocked = history.reduce((sum, point) => sum + point.blocked, 0); + const tasksWithDuration = filteredTasks.filter((task) => task.durationMs); const avgDuration = Math.round( - hermesTasks.filter((task) => task.durationMs).reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / - Math.max(1, hermesTasks.filter((task) => task.durationMs).length) / 60000, + tasksWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / + Math.max(1, tasksWithDuration.length) / 60000, ); const failureReasons = [ diff --git a/dashboard/web/src/app/hermes/layout.tsx b/dashboard/web/src/app/hermes/layout.tsx index f6cd96b..2c0b63d 100644 --- a/dashboard/web/src/app/hermes/layout.tsx +++ b/dashboard/web/src/app/hermes/layout.tsx @@ -1,14 +1,26 @@ 'use client'; import { SidebarNav } from '@/components/sidebar-nav'; +import { HermesInstanceSwitcher } from '@/components/hermes-instance-switcher'; +import { HermesInstanceProvider } from '@/lib/hermes-instance-context'; export default function HermesLayout({ children }: { children: React.ReactNode }) { return ( -
- -
-
{children}
-
-
+ +
+ +
+
+ {/* Global instance switcher — every Mission Control pane reads + from the same `useHermesInstance()` hook, so this filter + propagates everywhere. */} +
+ +
+ {children} +
+
+
+
); } diff --git a/dashboard/web/src/app/hermes/page.tsx b/dashboard/web/src/app/hermes/page.tsx index cb5c2af..0756fd1 100644 --- a/dashboard/web/src/app/hermes/page.tsx +++ b/dashboard/web/src/app/hermes/page.tsx @@ -1,10 +1,13 @@ 'use client'; +import { useMemo } from 'react'; import Link from 'next/link'; import { ArrowRight, BadgeCheck, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react'; import { Badge, Button } from '@/components/ui/Primitives'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; import { HermesOpsPanel } from '@/components/hermes-ops-panel'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; import { getHermesAgents, getHermesOverview, @@ -12,6 +15,7 @@ import { getHermesTasks, hermesProducts, hermesTasks, + HERMES_INSTANCES, type HermesProduct, type HermesTask, } from '@/lib/hermes'; @@ -51,7 +55,10 @@ function ProductMiniCard({ product }: { product: HermesProduct }) {

{product.name}

{product.category} · {product.priority}

- {product.needsAttention ? 'Attention' : 'Healthy'} +
+ + {product.needsAttention ? 'Attention' : 'Healthy'} +
@@ -72,19 +79,51 @@ function ProductMiniCard({ product }: { product: HermesProduct }) { } export default function HermesMissionControlPage() { - const overview = getHermesOverview(); - const activeTasks = getHermesTasks({ status: 'running' }).concat(getHermesTasks({ status: 'blocked' }), getHermesTasks({ status: 'queued' })).slice(0, 8); - const attentionTasks = getHermesTasks({ status: 'blocked' }).concat(getHermesTasks({ status: 'failed' })).slice(0, 8); - const recentProducts = hermesProducts + const { selectedInstance } = useHermesInstance(); + const overview = useMemo(() => getHermesOverview(selectedInstance), [selectedInstance]); + // Per-instance roll-up cards always show both Vijay and Bheem regardless of + // the active filter — they're the "comparison" view that sits next to the + // filtered overview metrics. This satisfies the roadmap's "per-instance + // cards AND a combined roll-up" requirement. + const perInstance = useMemo( + () => HERMES_INSTANCES.map((inst) => ({ ...inst, overview: getHermesOverview(inst.id) })), + [], + ); + + const activeTasks = useMemo( + () => getHermesTasks({ status: 'running', instance: selectedInstance }) + .concat(getHermesTasks({ status: 'blocked', instance: selectedInstance })) + .concat(getHermesTasks({ status: 'queued', instance: selectedInstance })) + .slice(0, 8), + [selectedInstance], + ); + const attentionTasks = useMemo( + () => getHermesTasks({ status: 'blocked', instance: selectedInstance }) + .concat(getHermesTasks({ status: 'failed', instance: selectedInstance })) + .slice(0, 8), + [selectedInstance], + ); + const filteredProducts = useMemo( + () => (selectedInstance === 'all' ? hermesProducts : hermesProducts.filter((p) => p.instanceId === selectedInstance)), + [selectedInstance], + ); + const filteredTasks = useMemo( + () => (selectedInstance === 'all' ? hermesTasks : hermesTasks.filter((t) => t.instanceId === selectedInstance)), + [selectedInstance], + ); + const recentProducts = filteredProducts .filter((product) => product.lastHermesActivityAt) .sort((a, b) => new Date(b.lastHermesActivityAt!).getTime() - new Date(a.lastHermesActivityAt!).getTime()) .slice(0, 8); - const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000); - const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000); - const failedTasks = hermesTasks.filter((task) => task.status === 'failed'); - const repeatedFailures = getHermesProducts('repeated-failures').slice(0, 5); - const actionableProducts = hermesProducts.filter((product) => product.needsAttention).slice(0, 6); - const agentStatuses = getHermesAgents(); + const completedToday = filteredTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000); + const completedThisWeek = filteredTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000); + const failedTasks = filteredTasks.filter((task) => task.status === 'failed'); + const repeatedFailures = useMemo( + () => getHermesProducts('repeated-failures', selectedInstance).slice(0, 5), + [selectedInstance], + ); + const actionableProducts = filteredProducts.filter((product) => product.needsAttention).slice(0, 6); + const agentStatuses = useMemo(() => getHermesAgents(selectedInstance), [selectedInstance]); const autoActions = [ 'Continue the queued execution lane for high-priority product updates.', 'Publish a weekly digest from completed and failed work.', @@ -118,6 +157,32 @@ export default function HermesMissionControlPage() { } helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} /> + Always cross-instance} + > +
+ {perInstance.map(({ id, label, description, overview: ov }) => ( +
+
+
+

{label}

+

{description}

+
+ {ov.status} +
+
+
Active
{ov.activeTasks}
+
Blocked
{ov.blockedTasks}
+
Failed
{ov.failedTasks}
+
Success %
{ov.successRate}%
+
+
+ ))} +
+
+
@@ -133,6 +198,7 @@ export default function HermesMissionControlPage() { {task.title} {taskStatusLabel(task)} {task.priority} +

{product?.name ?? 'Unknown product'} · {task.assignedAgent} · {task.type}

diff --git a/dashboard/web/src/app/hermes/products/page.tsx b/dashboard/web/src/app/hermes/products/page.tsx index 781a4ac..608838e 100644 --- a/dashboard/web/src/app/hermes/products/page.tsx +++ b/dashboard/web/src/app/hermes/products/page.tsx @@ -5,6 +5,8 @@ import Link from 'next/link'; import { Filter, Orbit, Rocket, ShieldAlert, Sparkles, TrendingUp } from 'lucide-react'; import { Badge, Button, Input } from '@/components/ui/Primitives'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; import { getHermesProducts, getHermesTasks, hermesProducts, type HermesProduct } from '@/lib/hermes'; const views = [ @@ -33,7 +35,10 @@ function ProductCard({ product }: { product: HermesProduct }) { {product.name}

{product.category} · {product.owner}

- {product.status} +
+ + {product.status} +

{product.description}

@@ -54,12 +59,13 @@ export default function HermesProductsPage() { const [query, setQuery] = useState(''); const [view, setView] = useState<(typeof views)[number]['key']>('all'); + const { selectedInstance } = useHermesInstance(); const products = useMemo(() => { - return getHermesProducts(view).filter((product) => { + return getHermesProducts(view, selectedInstance).filter((product) => { const haystack = [product.name, product.slug, product.category, product.owner, product.description, ...product.tags].join(' ').toLowerCase(); return haystack.includes(query.toLowerCase().trim()); }); - }, [query, view]); + }, [query, view, selectedInstance]); const attentionCount = hermesProducts.filter((product) => product.needsAttention).length; const highPriorityCount = hermesProducts.filter((product) => product.priority === 'P0' || product.priority === 'P1').length; diff --git a/dashboard/web/src/app/hermes/tasks/page.tsx b/dashboard/web/src/app/hermes/tasks/page.tsx index 42ca226..f913369 100644 --- a/dashboard/web/src/app/hermes/tasks/page.tsx +++ b/dashboard/web/src/app/hermes/tasks/page.tsx @@ -5,6 +5,8 @@ import Link from 'next/link'; import { Download, Filter, Search, ChevronDown, ChevronUp, ArrowLeftRight } from 'lucide-react'; import { Badge, Button, Input } from '@/components/ui/Primitives'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; import { getHermesProductById, getHermesTasks, @@ -49,7 +51,11 @@ export default function HermesTaskLedgerPage() { const [page, setPage] = useState(1); const [expandedTaskId, setExpandedTaskId] = useState(null); - const tasks = useMemo(() => getHermesTasks({ query, status, productId, priority, type, source, updatedWithinDays, sort }), [query, status, productId, priority, type, source, updatedWithinDays, sort]); + const { selectedInstance } = useHermesInstance(); + const tasks = useMemo( + () => getHermesTasks({ query, status, productId, priority, type, source, instance: selectedInstance, updatedWithinDays, sort }), + [query, status, productId, priority, type, source, selectedInstance, updatedWithinDays, sort], + ); const totalPages = Math.max(1, Math.ceil(tasks.length / pageSize)); const pagedTasks = tasks.slice((page - 1) * pageSize, page * pageSize); @@ -145,7 +151,12 @@ export default function HermesTaskLedgerPage() {

{task.description}

- {product?.name ?? 'Unknown'} + +
+ {product?.name ?? 'Unknown'} + +
+ {task.status} {task.priority} {task.type} diff --git a/dashboard/web/src/components/hermes-instance-switcher.tsx b/dashboard/web/src/components/hermes-instance-switcher.tsx new file mode 100644 index 0000000..bf782a0 --- /dev/null +++ b/dashboard/web/src/components/hermes-instance-switcher.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Badge } from '@/components/ui/Primitives'; +import { cn } from '@/lib/utils'; +import { HERMES_INSTANCES, type HermesInstanceFilter, type HermesInstanceId } from '@/lib/hermes'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; + +const OPTIONS: Array<{ id: HermesInstanceFilter; label: string }> = [ + { id: 'all', label: 'All' }, + ...HERMES_INSTANCES.map((inst) => ({ id: inst.id, label: inst.label })), +]; + +interface HermesInstanceSwitcherProps { + className?: string; +} + +// Segmented control mounted in the Hermes layout header. Persists its +// selection via `useHermesInstance` (localStorage-backed); every Mission +// Control pane reads from the same hook. +export function HermesInstanceSwitcher({ className }: HermesInstanceSwitcherProps) { + const { selectedInstance, setSelectedInstance } = useHermesInstance(); + + return ( +
+ {OPTIONS.map((option) => { + const active = option.id === selectedInstance; + return ( + + ); + })} +
+ ); +} + +// Small per-row badge so table cells can show which instance owns the entity. +// Use `'all'` for entities scoped to both instances (e.g. cross-cutting agents +// like Hermes Core, GitHub link). +const INSTANCE_LABEL: Record = { + vijay: 'Vijay', + bheem: 'Bheem', + all: 'Both', +}; + +export function HermesInstanceBadge({ instanceId }: { instanceId: HermesInstanceId | 'all' }) { + return {INSTANCE_LABEL[instanceId]}; +} diff --git a/dashboard/web/src/lib/hermes-instance-context.tsx b/dashboard/web/src/lib/hermes-instance-context.tsx new file mode 100644 index 0000000..abfde18 --- /dev/null +++ b/dashboard/web/src/lib/hermes-instance-context.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import type { HermesInstanceFilter } from './hermes'; + +// Persisted across navigation so the switcher choice sticks. Bumping the key +// resets the stored value (use this if we ever change the allowed filter +// values incompatibly). +const STORAGE_KEY = 'hermes.instanceFilter.v1'; +const DEFAULT_FILTER: HermesInstanceFilter = 'all'; + +const VALID_FILTERS: HermesInstanceFilter[] = ['all', 'vijay', 'bheem']; + +function readPersisted(): HermesInstanceFilter { + if (typeof window === 'undefined') return DEFAULT_FILTER; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw && (VALID_FILTERS as string[]).includes(raw)) { + return raw as HermesInstanceFilter; + } + } catch { + // localStorage may be unavailable (private mode / SSR). Fall through to default. + } + return DEFAULT_FILTER; +} + +interface HermesInstanceContextValue { + selectedInstance: HermesInstanceFilter; + setSelectedInstance: (next: HermesInstanceFilter) => void; +} + +const HermesInstanceContext = createContext(null); + +export function HermesInstanceProvider({ children }: { children: ReactNode }) { + // Always start with the default on the server so the SSR + first client render + // agree (avoids hydration mismatch). After mount we read the persisted value. + const [selectedInstance, setSelectedInstanceState] = useState(DEFAULT_FILTER); + + useEffect(() => { + setSelectedInstanceState(readPersisted()); + }, []); + + const setSelectedInstance = useCallback((next: HermesInstanceFilter) => { + setSelectedInstanceState(next); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // Best-effort persistence; ignore failures. + } + } + }, []); + + const value = useMemo( + () => ({ selectedInstance, setSelectedInstance }), + [selectedInstance, setSelectedInstance], + ); + + return {children}; +} + +export function useHermesInstance(): HermesInstanceContextValue { + const ctx = useContext(HermesInstanceContext); + if (!ctx) { + // Allow components to render outside the provider (e.g. ad-hoc usage in + // storybook/tests) by falling back to the default — never crash the tree. + return { selectedInstance: DEFAULT_FILTER, setSelectedInstance: () => {} }; + } + return ctx; +} diff --git a/dashboard/web/src/lib/hermes.test.ts b/dashboard/web/src/lib/hermes.test.ts index e66718f..3c855e2 100644 --- a/dashboard/web/src/lib/hermes.test.ts +++ b/dashboard/web/src/lib/hermes.test.ts @@ -47,4 +47,38 @@ describe('hermes mock service', () => { expect(getHermesSettings().registry.length).toBeGreaterThan(0); expect(getHermesProductById(hermesProducts[0].id)).toBeDefined(); }); + + it('tags every product/task with a Hermes instance', () => { + expect(hermesProducts.every((p) => p.instanceId === 'vijay' || p.instanceId === 'bheem')).toBe(true); + expect(hermesTasks.every((t) => t.instanceId === 'vijay' || t.instanceId === 'bheem')).toBe(true); + // Both instances should be represented in the seed data so the switcher + // doesn't bottom out on either one. + expect(hermesProducts.some((p) => p.instanceId === 'vijay')).toBe(true); + expect(hermesProducts.some((p) => p.instanceId === 'bheem')).toBe(true); + }); + + it('filters tasks/products/agents/overview by instance', () => { + const vijayTasks = getHermesTasks({ instance: 'vijay' }); + const bheemTasks = getHermesTasks({ instance: 'bheem' }); + expect(vijayTasks.every((t) => t.instanceId === 'vijay')).toBe(true); + expect(bheemTasks.every((t) => t.instanceId === 'bheem')).toBe(true); + expect(vijayTasks.length + bheemTasks.length).toBe(hermesTasks.length); + + const vijayProducts = getHermesProducts('all', 'vijay'); + expect(vijayProducts.every((p) => p.instanceId === 'vijay')).toBe(true); + + // Cross-cutting agents (scope `'all'`) appear in every per-instance view; + // strict per-instance agents only appear when their instance matches. + const vijayAgents = getHermesAgents('vijay'); + const bheemAgents = getHermesAgents('bheem'); + expect(vijayAgents.every((a) => a.instanceId === 'vijay' || a.instanceId === 'all')).toBe(true); + expect(bheemAgents.every((a) => a.instanceId === 'bheem' || a.instanceId === 'all')).toBe(true); + expect(vijayAgents.some((a) => a.id === 'cli-runner')).toBe(true); + expect(bheemAgents.some((a) => a.id === 'local-vm-runner')).toBe(true); + expect(vijayAgents.some((a) => a.id === 'local-vm-runner')).toBe(false); + + const overviewAll = getHermesOverview('all'); + const overviewVijay = getHermesOverview('vijay'); + expect(overviewVijay.activeTasks).toBeLessThanOrEqual(overviewAll.activeTasks); + }); }); diff --git a/dashboard/web/src/lib/hermes.ts b/dashboard/web/src/lib/hermes.ts index 8e72fe9..2564c7a 100644 --- a/dashboard/web/src/lib/hermes.ts +++ b/dashboard/web/src/lib/hermes.ts @@ -1,3 +1,20 @@ +// Hermes runs as two co-located instances ("Vijay" on the root host, "Bheem" +// under the `uma` user). Every entity that flows through Mission Control — +// tasks, products, events, runs, agents — is tagged with the instance that +// owns it so panes can filter or roll up across instances. The literal `'all'` +// is a UI-only filter value (never stored on entities). +export type HermesInstanceId = 'vijay' | 'bheem'; +export type HermesInstanceFilter = 'all' | HermesInstanceId; + +export const HERMES_INSTANCES: ReadonlyArray<{ + id: HermesInstanceId; + label: string; + description: string; +}> = [ + { id: 'vijay', label: 'Vijay (root)', description: 'Primary host instance' }, + { id: 'bheem', label: 'Bheem (uma)', description: 'Secondary user instance' }, +]; + export type HermesStatus = 'running' | 'idle' | 'degraded' | 'error'; export type HermesTaskStatus = @@ -39,6 +56,7 @@ export type HermesTaskSource = export interface HermesProduct { id: string; + instanceId: HermesInstanceId; name: string; slug: string; description: string; @@ -61,6 +79,7 @@ export interface HermesProduct { export interface HermesTask { id: string; + instanceId: HermesInstanceId; title: string; description: string; productId: string; @@ -87,6 +106,7 @@ export interface HermesTask { export interface HermesEvent { id: string; + instanceId: HermesInstanceId; taskId: string; timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; @@ -114,6 +134,7 @@ export interface HermesEvent { export interface HermesRun { id: string; + instanceId: HermesInstanceId; taskId: string; startedAt: string; endedAt?: string; @@ -128,6 +149,11 @@ export interface HermesRun { export interface HermesAgentStatus { id: string; + // An agent's "scope" — which Hermes instance(s) it serves. Some integrations + // (Hermes Core, GitHub link) span both; we model that with a literal `'all'` + // rather than duplicating rows. Filtering treats `'all'` as a match for any + // selected instance. + instanceId: HermesInstanceId | 'all'; name: string; type: 'agent' | 'tool' | 'integration' | 'runner'; status: 'healthy' | 'degraded' | 'offline' | 'unknown'; @@ -250,8 +276,14 @@ export const hermesProducts: HermesProduct[] = Array.from({ length: 50 }, (_, in ), ); + // Deterministic split: every other product to Bheem (uma), the rest to + // Vijay (root). Gives a roughly 50/50 mix so the switcher is exercisable + // out of the box without any single instance going empty. + const instanceId: HermesInstanceId = index % 2 === 0 ? 'vijay' : 'bheem'; + return { id: `product-${ordinal}`, + instanceId, name: `${seed.name} ${ordinal}`, slug: `${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}`, description: `${seed.category} product managed by Hermes for ${seed.owner.toLowerCase()} workflows.`, @@ -321,6 +353,7 @@ export const hermesTasks: HermesTask[] = Array.from({ length: 36 }, (_, index) = return { id: `task-${index + 1}`, + instanceId: product.instanceId, title, description: `Hermes is working on ${taskTemplates[index % taskTemplates.length]} for ${product.name}.`, productId: product.id, @@ -354,6 +387,7 @@ const eventBlueprints = new Map( const events: HermesEvent[] = [ { id: `${task.id}-event-created`, + instanceId: task.instanceId, taskId: task.id, timestamp: created, level: 'info', @@ -363,6 +397,7 @@ const eventBlueprints = new Map( }, { id: `${task.id}-event-planned`, + instanceId: task.instanceId, taskId: task.id, timestamp: started, level: 'info', @@ -374,6 +409,7 @@ const eventBlueprints = new Map( if (task.status === 'running' || task.status === 'completed' || task.status === 'blocked' || task.status === 'failed') { events.push({ id: `${task.id}-event-started`, + instanceId: task.instanceId, taskId: task.id, timestamp: started, level: 'success', @@ -382,6 +418,7 @@ const eventBlueprints = new Map( }); events.push({ id: `${task.id}-event-command`, + instanceId: task.instanceId, taskId: task.id, timestamp: isoMinutesAgo(index * 7 + 25), level: 'debug', @@ -395,6 +432,7 @@ const eventBlueprints = new Map( if (task.status === 'blocked') { events.push({ id: `${task.id}-event-blocked`, + instanceId: task.instanceId, taskId: task.id, timestamp: isoMinutesAgo(index * 7 + 10), level: 'warn', @@ -407,6 +445,7 @@ const eventBlueprints = new Map( if (task.status === 'failed') { events.push({ id: `${task.id}-event-error`, + instanceId: task.instanceId, taskId: task.id, timestamp: isoMinutesAgo(index * 7 + 8), level: 'error', @@ -416,6 +455,7 @@ const eventBlueprints = new Map( }); events.push({ id: `${task.id}-event-retry`, + instanceId: task.instanceId, taskId: task.id, timestamp: isoMinutesAgo(index * 7 + 5), level: 'warn', @@ -427,6 +467,7 @@ const eventBlueprints = new Map( if (task.status === 'completed' || task.status === 'skipped') { events.push({ id: `${task.id}-event-completed`, + instanceId: task.instanceId, taskId: task.id, timestamp: completed, level: 'success', @@ -437,6 +478,7 @@ const eventBlueprints = new Map( if (task.type === 'deploy') { events.push({ id: `${task.id}-event-deployment`, + instanceId: task.instanceId, taskId: task.id, timestamp: completed, level: 'success', @@ -449,6 +491,7 @@ const eventBlueprints = new Map( if (task.priority === 'P0') { events.push({ id: `${task.id}-event-memory`, + instanceId: task.instanceId, taskId: task.id, timestamp: isoMinutesAgo(index * 7 + 2), level: 'info', @@ -464,6 +507,7 @@ const eventBlueprints = new Map( export const hermesAgentStatuses: HermesAgentStatus[] = [ { id: 'hermes-core', + instanceId: 'all', name: 'Hermes Core', type: 'agent', status: 'healthy', @@ -474,6 +518,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'openclaw-integration', + instanceId: 'all', name: 'OpenClaw integration', type: 'integration', status: 'degraded', @@ -486,6 +531,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'github-link', + instanceId: 'all', name: 'GitHub integration', type: 'integration', status: 'healthy', @@ -496,6 +542,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'local-vm-runner', + instanceId: 'bheem', name: 'Local VM runner', type: 'runner', status: 'healthy', @@ -506,6 +553,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'cli-runner', + instanceId: 'vijay', name: 'CLI runner', type: 'runner', status: 'healthy', @@ -516,6 +564,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'scheduler-cron', + instanceId: 'vijay', name: 'Scheduler / cron', type: 'tool', status: 'healthy', @@ -526,6 +575,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'deployment-tools', + instanceId: 'all', name: 'Deployment tools', type: 'tool', status: 'degraded', @@ -538,6 +588,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [ }, { id: 'notifications', + instanceId: 'all', name: 'Notification tools', type: 'tool', status: 'offline', @@ -608,26 +659,46 @@ export interface HermesTaskFilters { priority?: HermesPriority | 'all'; type?: HermesTaskType | 'all'; source?: HermesTaskSource | 'all'; + // Restrict to a single Hermes instance, or roll up across both with `'all'`. + instance?: HermesInstanceFilter; updatedWithinDays?: number | 'all'; sort?: 'newest' | 'oldest' | 'priority' | 'status'; } -export function getHermesOverview(): HermesOverview { - const activeTasks = hermesTasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length; - const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length; - const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length; - const failedTasks = hermesTasks.filter((task) => task.status === 'failed').length; - const blockedTasks = hermesTasks.filter((task) => task.status === 'blocked').length; - const completedWithDuration = hermesTasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed'); +// Helper used by every list-fetcher in this module so the filter semantics +// stay consistent: an entity matches when the requested filter is `'all'` OR +// equals the entity's own instance. For agents whose own scope is `'all'`, +// they always match (they live on both instances). +function instanceMatches( + entityScope: HermesInstanceId | 'all', + filter: HermesInstanceFilter, +): boolean { + if (filter === 'all') return true; + if (entityScope === 'all') return true; + return entityScope === filter; +} + +export function getHermesOverview(instance: HermesInstanceFilter = 'all'): HermesOverview { + const tasks = hermesTasks.filter((task) => instanceMatches(task.instanceId, instance)); + const products = hermesProducts.filter((product) => instanceMatches(product.instanceId, instance)); + + const activeTasks = tasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length; + const completedToday = tasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length; + const completedThisWeek = tasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length; + const failedTasks = tasks.filter((task) => task.status === 'failed').length; + const blockedTasks = tasks.filter((task) => task.status === 'blocked').length; + const completedWithDuration = tasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed'); const averageDurationMs = completedWithDuration.length ? Math.round(completedWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / completedWithDuration.length) : 0; - const successRate = Math.round((hermesTasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / hermesTasks.length) * 100); - const productsTouchedRecently = hermesProducts.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length; - const founderAttentionCount = hermesTasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + hermesProducts.filter((product) => product.needsAttention).length; - const upcomingJobs = hermesTasks.filter((task) => task.status === 'queued').length; - const lastAction = hermesEventsSorted()[0]?.message ?? 'Hermes has not recorded an action yet.'; - const nextRecommendedAction = computeNextRecommendedAction(); + const successRate = tasks.length === 0 + ? 0 + : Math.round((tasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / tasks.length) * 100); + const productsTouchedRecently = products.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length; + const founderAttentionCount = tasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + products.filter((product) => product.needsAttention).length; + const upcomingJobs = tasks.filter((task) => task.status === 'queued').length; + const lastAction = hermesEventsSorted(instance)[0]?.message ?? 'Hermes has not recorded an action yet.'; + const nextRecommendedAction = computeNextRecommendedAction(instance); return { status: failedTasks > 6 ? 'error' : blockedTasks > 4 ? 'degraded' : activeTasks > 0 ? 'running' : 'idle', @@ -654,12 +725,14 @@ export function getHermesTasks(filters: HermesTaskFilters = {}): HermesTask[] { priority = 'all', type = 'all', source = 'all', + instance = 'all', updatedWithinDays = 'all', sort = 'newest', } = filters; const normalizedQuery = query?.trim().toLowerCase(); const filtered = hermesTasks.filter((task) => { + if (!instanceMatches(task.instanceId, instance)) return false; const product = hermesProducts.find((item) => item.id === task.productId); const matchesQuery = !normalizedQuery || [ task.title, @@ -718,10 +791,14 @@ export function getHermesProductById(id: string): HermesProduct | undefined { return hermesProducts.find((product) => product.id === id); } -export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all'): HermesProduct[] { +export function getHermesProducts( + view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all', + instance: HermesInstanceFilter = 'all', +): HermesProduct[] { const recentCutoff = now - 14 * 86_400_000; const shippedCutoff = now - 7 * 86_400_000; return hermesProducts.filter((product) => { + if (!instanceMatches(product.instanceId, instance)) return false; const recentFailedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'failed').length; const recentCompletedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'completed').length; switch (view) { @@ -742,37 +819,51 @@ export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attenti }); } -export function getHermesHistory() { - return hermesHistory; +export function getHermesHistory(instance: HermesInstanceFilter = 'all'): HermesHistoryPoint[] { + // History is hand-tuned aggregate seed data. For the per-instance filter we + // approximate by halving each bar (instances were seeded ~50/50). This is + // mock data — Phase 3 replaces it with real per-instance time series. + if (instance === 'all') return hermesHistory; + return hermesHistory.map((point) => ({ + label: point.label, + completed: Math.round(point.completed / 2), + failed: Math.round(point.failed / 2), + blocked: Math.round(point.blocked / 2), + active: Math.round(point.active / 2), + })); } -export function getHermesAgents() { - return hermesAgentStatuses; +export function getHermesAgents(instance: HermesInstanceFilter = 'all'): HermesAgentStatus[] { + return hermesAgentStatuses.filter((agent) => instanceMatches(agent.instanceId, instance)); } export function getHermesSettings() { return hermesSettings; } -function hermesEventsSorted() { +function hermesEventsSorted(instance: HermesInstanceFilter = 'all') { return Array.from(eventBlueprints.values()) .flat() + .filter((event) => instanceMatches(event.instanceId, instance)) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); } -function computeNextRecommendedAction() { - const blocked = hermesTasks.filter((task) => task.status === 'blocked'); +function computeNextRecommendedAction(instance: HermesInstanceFilter = 'all') { + const tasks = hermesTasks.filter((task) => instanceMatches(task.instanceId, instance)); + const products = hermesProducts.filter((product) => instanceMatches(product.instanceId, instance)); + + const blocked = tasks.filter((task) => task.status === 'blocked'); if (blocked.length > 0) { const next = blocked[0]; return `Unblock ${next.title} for ${getHermesProductById(next.productId)?.name ?? 'an active product'}.`; } - const failed = hermesTasks.find((task) => task.status === 'failed'); + const failed = tasks.find((task) => task.status === 'failed'); if (failed) { return `Inspect and retry ${failed.title}.`; } - const staleProduct = hermesProducts.find((product) => product.needsAttention); + const staleProduct = products.find((product) => product.needsAttention); if (staleProduct) { return `Review ${staleProduct.name} because it needs attention.`; } diff --git a/docs/hermes_dashboard_v2_roadmap.md b/docs/hermes_dashboard_v2_roadmap.md index b2b1d21..cdd7688 100644 --- a/docs/hermes_dashboard_v2_roadmap.md +++ b/docs/hermes_dashboard_v2_roadmap.md @@ -87,10 +87,10 @@ The `hermes-ops` snapshot becomes the single source of truth for live status. Be ## Phase 2 — Instance dimension across Mission Control (G2) -- [ ] Add `instanceId: 'vijay' | 'bheem'` to the core types in `web/src/lib/hermes` (`HermesTask`, `HermesProduct`, `HermesEvent`, `HermesRun`, agent/overview models) and to the backend contracts. -- [ ] Add a global **instance switcher** in `HermesShell` (`All` / `Vijay (root)` / `Bheem (uma)`) with persisted selection; thread it through every pane. -- [ ] Overview: show per-instance cards **and** a combined roll-up (extend the existing "Healthy instances 2/2" pattern from the ops panel to the whole overview). -- [ ] Ledger / Products / History / Agents: filter and badge by instance. +- [x] Add `instanceId: 'vijay' | 'bheem'` to the core types in `web/src/lib/hermes` (`HermesTask`, `HermesProduct`, `HermesEvent`, `HermesRun`, agent/overview models) and to the backend contracts. *(Web: `instanceId` now on `HermesProduct`, `HermesTask`, `HermesEvent`, `HermesRun`, `HermesAgentStatus` (with a `'all'` literal for cross-cutting agents like Hermes Core / GitHub link). Seed data deterministically split ~50/50 across instances. Backend ops contract already carried per-instance shape under `HermesOpsSnapshot.instances` from Phase 1; no separate backend change needed for this slice.)* +- [x] Add a global **instance switcher** in `HermesShell` (`All` / `Vijay (root)` / `Bheem (uma)`) with persisted selection; thread it through every pane. *(New `HermesInstanceProvider` (React context, localStorage-backed under key `hermes.instanceFilter.v1`, with SSR-safe default to avoid hydration mismatch) mounted in `app/hermes/layout.tsx`. New `HermesInstanceSwitcher` segmented control rendered in the layout header above every pane. Every pane reads `useHermesInstance()` and threads the value into the data-fetcher.)* +- [x] Overview: show per-instance cards **and** a combined roll-up. *(New "Per-instance roll-up" section on `/hermes` always shows Vijay and Bheem side-by-side with active/blocked/failed/success-rate cells regardless of the switcher state — that's the "always cross-instance" comparison view, while the eight metric cards above it are filtered by the switcher.)* +- [x] Ledger / Products / History / Agents: filter and badge by instance. *(`HermesInstanceBadge` component shipped; tasks (Active Missions + Task Ledger), product cards (overview minicards + portfolio cards), and agent rows now show their instance. Filter helpers `getHermesTasks({instance})`, `getHermesProducts(view, instance)`, `getHermesAgents(instance)`, `getHermesHistory(instance)`, `getHermesOverview(instance)` all accept the filter and short-circuit `'all'`. New unit tests in `lib/hermes.test.ts` cover the filter semantics. New E2E test asserts the switcher's radiogroup, default selection, and persistence-friendly state change. 7/7 E2E + 13/13 web unit tests green.)* ## Phase 3 — Real per-instance telemetry, replacing mock pane by pane (G1, G4) @@ -186,7 +186,7 @@ Update only with evidence (source review, tests, build output, or browser/VM ver - [ ] Phase 0 — Guardrails reconfirmed - [x] Phase 1 — `hermes-ops` hardened + tested -- [ ] Phase 2 — Instance dimension + switcher +- [x] Phase 2 — Instance dimension + switcher - [ ] Phase 3 — Real telemetry ingestion + panes converted - [ ] Phase 4 — Bheem/Uma parity (backup, watchdog, restore drill) - [x] Phase 5 — App/CI hardening (P0/P1/P2 done; P2 follow-ups in DEPLOYMENT.md mitigation roadmap remain)