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>
873 lines
30 KiB
TypeScript
873 lines
30 KiB
TypeScript
// 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 =
|
|
| 'queued'
|
|
| 'running'
|
|
| 'blocked'
|
|
| 'completed'
|
|
| 'failed'
|
|
| 'skipped'
|
|
| 'cancelled';
|
|
|
|
export type HermesPriority = 'P0' | 'P1' | 'P2' | 'P3';
|
|
|
|
export type HermesTaskType =
|
|
| 'build'
|
|
| 'deploy'
|
|
| 'bugfix'
|
|
| 'monitoring'
|
|
| 'audit'
|
|
| 'refactor'
|
|
| 'documentation'
|
|
| 'research'
|
|
| 'security'
|
|
| 'cost-optimization'
|
|
| 'release'
|
|
| 'maintenance'
|
|
| 'product-planning';
|
|
|
|
export type HermesTaskSource =
|
|
| 'manual'
|
|
| 'cron'
|
|
| 'github'
|
|
| 'monitoring-alert'
|
|
| 'email'
|
|
| 'cli'
|
|
| 'webhook'
|
|
| 'local-agent'
|
|
| 'hermes-planner';
|
|
|
|
export interface HermesProduct {
|
|
id: string;
|
|
instanceId: HermesInstanceId;
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
category: string;
|
|
repoUrl?: string;
|
|
productionUrl?: string;
|
|
stagingUrl?: string;
|
|
owner: string;
|
|
priority: HermesPriority;
|
|
status: 'active' | 'paused' | 'maintenance' | 'archived' | 'idea';
|
|
healthScore: number;
|
|
tags: string[];
|
|
lastHermesActivityAt?: string;
|
|
lastDeploymentAt?: string;
|
|
lastCommitAt?: string;
|
|
needsAttention: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface HermesTask {
|
|
id: string;
|
|
instanceId: HermesInstanceId;
|
|
title: string;
|
|
description: string;
|
|
productId: string;
|
|
status: HermesTaskStatus;
|
|
priority: HermesPriority;
|
|
type: HermesTaskType;
|
|
source: HermesTaskSource;
|
|
createdAt: string;
|
|
startedAt?: string;
|
|
completedAt?: string;
|
|
durationMs?: number;
|
|
retryCount: number;
|
|
assignedAgent: string;
|
|
tags: string[];
|
|
progressPercent: number;
|
|
currentStep?: string;
|
|
lastAction?: string;
|
|
nextAction?: string;
|
|
blockerReason?: string;
|
|
summary?: string;
|
|
result?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface HermesEvent {
|
|
id: string;
|
|
instanceId: HermesInstanceId;
|
|
taskId: string;
|
|
timestamp: string;
|
|
level: 'debug' | 'info' | 'warn' | 'error' | 'success';
|
|
eventType:
|
|
| 'created'
|
|
| 'planned'
|
|
| 'started'
|
|
| 'tool-called'
|
|
| 'command-executed'
|
|
| 'file-changed'
|
|
| 'test-run'
|
|
| 'error'
|
|
| 'retry'
|
|
| 'blocked'
|
|
| 'completed'
|
|
| 'deployment'
|
|
| 'pr-created'
|
|
| 'memory-suggested';
|
|
message: string;
|
|
metadata?: Record<string, unknown>;
|
|
toolName?: string;
|
|
command?: string;
|
|
artifactUrl?: string;
|
|
}
|
|
|
|
export interface HermesRun {
|
|
id: string;
|
|
instanceId: HermesInstanceId;
|
|
taskId: string;
|
|
startedAt: string;
|
|
endedAt?: string;
|
|
status: HermesTaskStatus;
|
|
logs: string[];
|
|
metrics?: Record<string, number>;
|
|
commitSha?: string;
|
|
branchName?: string;
|
|
prUrl?: string;
|
|
deploymentUrl?: string;
|
|
}
|
|
|
|
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';
|
|
lastSuccessAt?: string;
|
|
lastFailureAt?: string;
|
|
callsToday: number;
|
|
failureRate: number;
|
|
averageLatencyMs?: number;
|
|
configIssue?: string;
|
|
}
|
|
|
|
export interface HermesOverview {
|
|
status: HermesStatus;
|
|
activeTasks: number;
|
|
completedToday: number;
|
|
completedThisWeek: number;
|
|
failedTasks: number;
|
|
blockedTasks: number;
|
|
averageDurationMs: number;
|
|
successRate: number;
|
|
productsTouchedRecently: number;
|
|
founderAttentionCount: number;
|
|
upcomingJobs: number;
|
|
lastAction: string;
|
|
nextRecommendedAction: string;
|
|
}
|
|
|
|
export interface HermesHistoryPoint {
|
|
label: string;
|
|
completed: number;
|
|
failed: number;
|
|
blocked: number;
|
|
active: number;
|
|
}
|
|
|
|
export interface HermesSettings {
|
|
demoMode: boolean;
|
|
retentionDays: number;
|
|
approvalThreshold: number;
|
|
autoRetryLimit: number;
|
|
notificationRules: Array<{
|
|
id: string;
|
|
label: string;
|
|
enabled: boolean;
|
|
target: string;
|
|
}>;
|
|
taskCategories: string[];
|
|
priorityRules: Array<{
|
|
priority: HermesPriority;
|
|
rule: string;
|
|
}>;
|
|
registry: Array<{
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
}>;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const isoMinutesAgo = (minutes: number) => new Date(now - minutes * 60_000).toISOString();
|
|
const isoHoursAgo = (hours: number) => new Date(now - hours * 3_600_000).toISOString();
|
|
const isoDaysAgo = (days: number) => new Date(now - days * 86_400_000).toISOString();
|
|
|
|
const productSeeds = [
|
|
{ name: 'Automation Hub', category: 'automation', owner: 'Hermes', tags: ['workflow', 'cron', 'ops'] },
|
|
{ name: 'Trading Console', category: 'SaaS', owner: 'Saravana', tags: ['deploy', 'reliability', 'money'] },
|
|
{ name: 'AI Content Studio', category: 'AI app', owner: 'Hermes', tags: ['llm', 'content', 'creative'] },
|
|
{ name: 'Internal Ops Desk', category: 'internal tool', owner: 'Platform', tags: ['admin', 'support', 'workflow'] },
|
|
{ name: 'Marketing Site', category: 'website', owner: 'Growth', tags: ['brand', 'web', 'seo'] },
|
|
{ name: 'Customer API', category: 'API', owner: 'Engineering', tags: ['api', 'integration', 'platform'] },
|
|
{ name: 'Agent Runner', category: 'automation', owner: 'Hermes', tags: ['agents', 'cli', 'background'] },
|
|
{ name: 'Browser Extension', category: 'browser extension', owner: 'Product', tags: ['extension', 'browser', 'ux'] },
|
|
{ name: 'Analytics Pipeline', category: 'data pipeline', owner: 'Data', tags: ['etl', 'data', 'metrics'] },
|
|
{ name: 'DevOps Toolkit', category: 'DevOps tool', owner: 'Saravana', tags: ['devops', 'scripts', 'gitea'] },
|
|
];
|
|
|
|
const priorityCycle: HermesPriority[] = ['P0', 'P1', 'P2', 'P3'];
|
|
const statusCycle: HermesProduct['status'][] = ['active', 'active', 'maintenance', 'paused', 'active', 'active', 'idea', 'archived'];
|
|
const taskStatusCycle: HermesTaskStatus[] = ['running', 'queued', 'blocked', 'completed', 'failed', 'completed', 'queued', 'running'];
|
|
const taskTypeCycle: HermesTaskType[] = [
|
|
'build',
|
|
'deploy',
|
|
'bugfix',
|
|
'monitoring',
|
|
'audit',
|
|
'refactor',
|
|
'documentation',
|
|
'research',
|
|
'security',
|
|
'cost-optimization',
|
|
'release',
|
|
'maintenance',
|
|
'product-planning',
|
|
];
|
|
const sourceCycle: HermesTaskSource[] = [
|
|
'manual',
|
|
'cron',
|
|
'github',
|
|
'monitoring-alert',
|
|
'email',
|
|
'cli',
|
|
'webhook',
|
|
'local-agent',
|
|
'hermes-planner',
|
|
];
|
|
|
|
export const hermesProducts: HermesProduct[] = Array.from({ length: 50 }, (_, index) => {
|
|
const seed = productSeeds[index % productSeeds.length];
|
|
const ordinal = index + 1;
|
|
const status = statusCycle[index % statusCycle.length];
|
|
const priority = priorityCycle[index % priorityCycle.length];
|
|
const activityAgeDays = (index % 18) + (status === 'active' ? 0 : 7);
|
|
const deploymentAgeDays = (index % 12) + (status === 'active' ? 1 : 14);
|
|
const commitAgeDays = (index % 9) + (status === 'active' ? 0 : 10);
|
|
const healthScore = Math.max(
|
|
32,
|
|
Math.min(
|
|
99,
|
|
94 - (index % 7) * 4 - (status === 'paused' ? 18 : 0) - (status === 'archived' ? 24 : 0) - (status === 'maintenance' ? 10 : 0) + (priority === 'P0' ? 4 : 0),
|
|
),
|
|
);
|
|
|
|
// 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.`,
|
|
category: seed.category,
|
|
repoUrl: `https://github.com/bytelyst/${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}`,
|
|
productionUrl: status === 'archived' ? undefined : `https://${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}.bytelyst.ai`,
|
|
stagingUrl: status === 'idea' ? undefined : `https://staging-${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}.bytelyst.ai`,
|
|
owner: seed.owner,
|
|
priority,
|
|
status,
|
|
healthScore,
|
|
tags: seed.tags,
|
|
lastHermesActivityAt: isoDaysAgo(activityAgeDays),
|
|
lastDeploymentAt: status === 'idea' ? undefined : isoDaysAgo(deploymentAgeDays),
|
|
lastCommitAt: isoDaysAgo(commitAgeDays),
|
|
needsAttention: healthScore < 72 || status !== 'active',
|
|
createdAt: isoDaysAgo(60 + index),
|
|
updatedAt: isoDaysAgo(index % 10),
|
|
};
|
|
});
|
|
|
|
const taskTemplates = [
|
|
'stabilize deployment pipeline',
|
|
'investigate retry loop',
|
|
'ship dashboard enhancement',
|
|
'review CI failure cluster',
|
|
'refactor service integration',
|
|
'document product launch flow',
|
|
'audit secrets and rotation',
|
|
'prepare release checklist',
|
|
'benchmark response latency',
|
|
'resolve blocked automation',
|
|
'ship product telemetry update',
|
|
'clean up stale jobs',
|
|
];
|
|
|
|
const assignedAgents = ['Hermes Core', 'OpenClaw', 'Gitea Bot', 'Local VM Runner', 'Planner', 'Notifier'];
|
|
|
|
export const hermesTasks: HermesTask[] = Array.from({ length: 36 }, (_, index) => {
|
|
const product = hermesProducts[index % hermesProducts.length];
|
|
const status = taskStatusCycle[index % taskStatusCycle.length];
|
|
const priority = priorityCycle[(index + (status === 'blocked' ? 0 : 1)) % priorityCycle.length];
|
|
const type = taskTypeCycle[index % taskTypeCycle.length];
|
|
const source = sourceCycle[index % sourceCycle.length];
|
|
const createdHoursAgo = index * 3 + 4;
|
|
const startedHoursAgo = createdHoursAgo - 1;
|
|
const durationMinutes = 16 + (index % 6) * 9;
|
|
const title = `${taskTemplates[index % taskTemplates.length]} — ${product.name}`;
|
|
const blockerReason = status === 'blocked'
|
|
? ['Waiting on approval', 'Missing credential', 'Test failure needs triage', 'Deployment gate is red'][index % 4]
|
|
: status === 'failed'
|
|
? ['Flaky integration test', 'Build timeout', 'Blocked by external dependency'][index % 3]
|
|
: undefined;
|
|
const result = status === 'completed'
|
|
? ['Merged and deployed', 'Doc updated and published', 'Audit completed, no action needed', 'PR opened for review'][index % 4]
|
|
: undefined;
|
|
const error = status === 'failed'
|
|
? ['npm test exited 1', 'upstream service returned 500', 'timeout waiting for health check'][index % 3]
|
|
: undefined;
|
|
const progressPercent =
|
|
status === 'completed' ? 100 :
|
|
status === 'running' ? 55 + (index % 20) :
|
|
status === 'queued' ? 10 + (index % 15) :
|
|
status === 'blocked' ? 35 :
|
|
status === 'failed' ? 48 :
|
|
status === 'skipped' ? 100 : 0;
|
|
|
|
return {
|
|
id: `task-${index + 1}`,
|
|
instanceId: product.instanceId,
|
|
title,
|
|
description: `Hermes is working on ${taskTemplates[index % taskTemplates.length]} for ${product.name}.`,
|
|
productId: product.id,
|
|
status,
|
|
priority,
|
|
type,
|
|
source,
|
|
createdAt: isoHoursAgo(createdHoursAgo),
|
|
startedAt: status === 'queued' ? undefined : isoHoursAgo(startedHoursAgo),
|
|
completedAt: status === 'completed' || status === 'skipped' ? isoHoursAgo(Math.max(0, startedHoursAgo - 1)) : undefined,
|
|
durationMs: status === 'completed' || status === 'failed' || status === 'skipped' ? durationMinutes * 60_000 : undefined,
|
|
retryCount: index % 4,
|
|
assignedAgent: assignedAgents[index % assignedAgents.length],
|
|
tags: [type, priority.toLowerCase(), product.category.toLowerCase()],
|
|
progressPercent,
|
|
currentStep: status === 'running' ? ['reviewing logs', 'applying patch', 'replaying checks', 'waiting for approval'][index % 4] : undefined,
|
|
lastAction: ['Checked repo state', 'Ran typecheck', 'Updated docs', 'Pushed branch', 'Re-ran tests'][index % 5],
|
|
nextAction: status === 'completed' ? 'Monitor telemetry' : status === 'blocked' ? 'Await founder decision' : status === 'failed' ? 'Investigate failure and retry' : 'Continue task execution',
|
|
blockerReason,
|
|
summary: status === 'completed' ? 'Work completed successfully with evidence captured.' : 'Active orchestration task tracked by Hermes.',
|
|
result,
|
|
error,
|
|
};
|
|
});
|
|
|
|
const eventBlueprints = new Map<string, HermesEvent[]>(
|
|
hermesTasks.map((task, index) => {
|
|
const created = isoHoursAgo(index * 3 + 4);
|
|
const started = isoHoursAgo(index * 3 + 3);
|
|
const completed = isoHoursAgo(index * 3 + 1);
|
|
const events: HermesEvent[] = [
|
|
{
|
|
id: `${task.id}-event-created`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: created,
|
|
level: 'info',
|
|
eventType: 'created',
|
|
message: `Task ${task.title} was created from ${task.source}.`,
|
|
metadata: { productId: task.productId, priority: task.priority },
|
|
},
|
|
{
|
|
id: `${task.id}-event-planned`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: started,
|
|
level: 'info',
|
|
eventType: 'planned',
|
|
message: `Hermes planned the next steps for ${task.title}.`,
|
|
},
|
|
];
|
|
|
|
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',
|
|
eventType: 'started',
|
|
message: `${task.assignedAgent} started execution.`,
|
|
});
|
|
events.push({
|
|
id: `${task.id}-event-command`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: isoMinutesAgo(index * 7 + 25),
|
|
level: 'debug',
|
|
eventType: 'command-executed',
|
|
message: 'Ran the verification command set.',
|
|
command: 'pnpm test:run && pnpm build',
|
|
toolName: 'terminal',
|
|
});
|
|
}
|
|
|
|
if (task.status === 'blocked') {
|
|
events.push({
|
|
id: `${task.id}-event-blocked`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: isoMinutesAgo(index * 7 + 10),
|
|
level: 'warn',
|
|
eventType: 'blocked',
|
|
message: task.blockerReason ?? 'Task blocked pending review.',
|
|
metadata: { attentionRequired: true },
|
|
});
|
|
}
|
|
|
|
if (task.status === 'failed') {
|
|
events.push({
|
|
id: `${task.id}-event-error`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: isoMinutesAgo(index * 7 + 8),
|
|
level: 'error',
|
|
eventType: 'error',
|
|
message: task.error ?? 'Execution failed.',
|
|
metadata: { retryCount: task.retryCount },
|
|
});
|
|
events.push({
|
|
id: `${task.id}-event-retry`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: isoMinutesAgo(index * 7 + 5),
|
|
level: 'warn',
|
|
eventType: 'retry',
|
|
message: 'Hermes scheduled an automatic retry.',
|
|
});
|
|
}
|
|
|
|
if (task.status === 'completed' || task.status === 'skipped') {
|
|
events.push({
|
|
id: `${task.id}-event-completed`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: completed,
|
|
level: 'success',
|
|
eventType: 'completed',
|
|
message: task.result ?? 'Task completed successfully.',
|
|
artifactUrl: `https://github.com/bytelyst/hermes/${task.id}`,
|
|
});
|
|
if (task.type === 'deploy') {
|
|
events.push({
|
|
id: `${task.id}-event-deployment`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: completed,
|
|
level: 'success',
|
|
eventType: 'deployment',
|
|
message: 'Deployment finished and health check passed.',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (task.priority === 'P0') {
|
|
events.push({
|
|
id: `${task.id}-event-memory`,
|
|
instanceId: task.instanceId,
|
|
taskId: task.id,
|
|
timestamp: isoMinutesAgo(index * 7 + 2),
|
|
level: 'info',
|
|
eventType: 'memory-suggested',
|
|
message: 'Hermes suggested a follow-up memory entry to prevent repeat failures.',
|
|
});
|
|
}
|
|
|
|
return [task.id, events];
|
|
}),
|
|
);
|
|
|
|
export const hermesAgentStatuses: HermesAgentStatus[] = [
|
|
{
|
|
id: 'hermes-core',
|
|
instanceId: 'all',
|
|
name: 'Hermes Core',
|
|
type: 'agent',
|
|
status: 'healthy',
|
|
lastSuccessAt: isoHoursAgo(1),
|
|
callsToday: 42,
|
|
failureRate: 0.03,
|
|
averageLatencyMs: 820,
|
|
},
|
|
{
|
|
id: 'openclaw-integration',
|
|
instanceId: 'all',
|
|
name: 'OpenClaw integration',
|
|
type: 'integration',
|
|
status: 'degraded',
|
|
lastSuccessAt: isoHoursAgo(5),
|
|
lastFailureAt: isoHoursAgo(1),
|
|
callsToday: 11,
|
|
failureRate: 0.18,
|
|
averageLatencyMs: 1480,
|
|
configIssue: 'Rate-limit warnings from the upstream workspace token.',
|
|
},
|
|
{
|
|
id: 'github-link',
|
|
instanceId: 'all',
|
|
name: 'GitHub integration',
|
|
type: 'integration',
|
|
status: 'healthy',
|
|
lastSuccessAt: isoHoursAgo(2),
|
|
callsToday: 84,
|
|
failureRate: 0.01,
|
|
averageLatencyMs: 420,
|
|
},
|
|
{
|
|
id: 'local-vm-runner',
|
|
instanceId: 'bheem',
|
|
name: 'Local VM runner',
|
|
type: 'runner',
|
|
status: 'healthy',
|
|
lastSuccessAt: isoMinutesAgo(18),
|
|
callsToday: 27,
|
|
failureRate: 0.02,
|
|
averageLatencyMs: 670,
|
|
},
|
|
{
|
|
id: 'cli-runner',
|
|
instanceId: 'vijay',
|
|
name: 'CLI runner',
|
|
type: 'runner',
|
|
status: 'healthy',
|
|
lastSuccessAt: isoMinutesAgo(6),
|
|
callsToday: 33,
|
|
failureRate: 0.02,
|
|
averageLatencyMs: 510,
|
|
},
|
|
{
|
|
id: 'scheduler-cron',
|
|
instanceId: 'vijay',
|
|
name: 'Scheduler / cron',
|
|
type: 'tool',
|
|
status: 'healthy',
|
|
lastSuccessAt: isoMinutesAgo(9),
|
|
callsToday: 67,
|
|
failureRate: 0.00,
|
|
averageLatencyMs: 112,
|
|
},
|
|
{
|
|
id: 'deployment-tools',
|
|
instanceId: 'all',
|
|
name: 'Deployment tools',
|
|
type: 'tool',
|
|
status: 'degraded',
|
|
lastSuccessAt: isoHoursAgo(3),
|
|
lastFailureAt: isoHoursAgo(1),
|
|
callsToday: 19,
|
|
failureRate: 0.11,
|
|
averageLatencyMs: 900,
|
|
configIssue: 'One stale secret needs rotation before the next release.',
|
|
},
|
|
{
|
|
id: 'notifications',
|
|
instanceId: 'all',
|
|
name: 'Notification tools',
|
|
type: 'tool',
|
|
status: 'offline',
|
|
lastSuccessAt: isoDaysAgo(2),
|
|
lastFailureAt: isoHoursAgo(9),
|
|
callsToday: 4,
|
|
failureRate: 0.25,
|
|
averageLatencyMs: 2100,
|
|
configIssue: 'Telegram webhook token not configured in the mock environment.',
|
|
},
|
|
];
|
|
|
|
export const hermesHistory: HermesHistoryPoint[] = [
|
|
{ label: 'Wk 1', completed: 12, failed: 2, blocked: 1, active: 4 },
|
|
{ label: 'Wk 2', completed: 18, failed: 1, blocked: 2, active: 5 },
|
|
{ label: 'Wk 3', completed: 15, failed: 4, blocked: 2, active: 6 },
|
|
{ label: 'Wk 4', completed: 21, failed: 3, blocked: 1, active: 7 },
|
|
{ label: 'Wk 5', completed: 17, failed: 2, blocked: 3, active: 6 },
|
|
{ label: 'Wk 6', completed: 24, failed: 1, blocked: 1, active: 5 },
|
|
{ label: 'Wk 7', completed: 20, failed: 2, blocked: 2, active: 6 },
|
|
{ label: 'Wk 8', completed: 26, failed: 1, blocked: 0, active: 4 },
|
|
];
|
|
|
|
export const hermesSettings: HermesSettings = {
|
|
demoMode: true,
|
|
retentionDays: 45,
|
|
approvalThreshold: 75,
|
|
autoRetryLimit: 2,
|
|
notificationRules: [
|
|
{ id: 'approval-needed', label: 'Approval needed', enabled: true, target: 'Telegram + dashboard badge' },
|
|
{ id: 'deploy-failure', label: 'Failed deployment', enabled: true, target: 'Telegram' },
|
|
{ id: 'repeated-failure', label: 'Repeated failures', enabled: true, target: 'Email digest' },
|
|
{ id: 'cost-risk', label: 'Cost or risk warning', enabled: false, target: 'Founder review queue' },
|
|
],
|
|
taskCategories: [
|
|
'build',
|
|
'deploy',
|
|
'bugfix',
|
|
'monitoring',
|
|
'audit',
|
|
'refactor',
|
|
'documentation',
|
|
'research',
|
|
'security',
|
|
'cost-optimization',
|
|
'release',
|
|
'maintenance',
|
|
'product-planning',
|
|
],
|
|
priorityRules: [
|
|
{ priority: 'P0', rule: 'Production incidents, blocked launches, or security issues.' },
|
|
{ priority: 'P1', rule: 'High-value shipping work with founder impact.' },
|
|
{ priority: 'P2', rule: 'Normal operating work and maintenance.' },
|
|
{ priority: 'P3', rule: 'Nice-to-have improvements and backlog hygiene.' },
|
|
],
|
|
registry: [
|
|
{ id: 'hermes-core', name: 'Hermes Core', enabled: true },
|
|
{ id: 'github', name: 'GitHub', enabled: true },
|
|
{ id: 'local-vm', name: 'Local VM Runner', enabled: true },
|
|
{ id: 'notifications', name: 'Notifications', enabled: false },
|
|
],
|
|
};
|
|
|
|
export interface HermesTaskFilters {
|
|
query?: string;
|
|
status?: HermesTaskStatus | 'all';
|
|
productId?: string | 'all';
|
|
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';
|
|
}
|
|
|
|
// 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 = 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',
|
|
activeTasks,
|
|
completedToday,
|
|
completedThisWeek,
|
|
failedTasks,
|
|
blockedTasks,
|
|
averageDurationMs,
|
|
successRate,
|
|
productsTouchedRecently,
|
|
founderAttentionCount,
|
|
upcomingJobs,
|
|
lastAction,
|
|
nextRecommendedAction,
|
|
};
|
|
}
|
|
|
|
export function getHermesTasks(filters: HermesTaskFilters = {}): HermesTask[] {
|
|
const {
|
|
query,
|
|
status = 'all',
|
|
productId = 'all',
|
|
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,
|
|
task.description,
|
|
task.assignedAgent,
|
|
task.lastAction,
|
|
task.nextAction,
|
|
task.blockerReason,
|
|
task.result,
|
|
task.error,
|
|
product?.name,
|
|
product?.slug,
|
|
...task.tags,
|
|
]
|
|
.filter(Boolean)
|
|
.some((value) => String(value).toLowerCase().includes(normalizedQuery));
|
|
|
|
const matchesStatus = status === 'all' || task.status === status;
|
|
const matchesProduct = productId === 'all' || task.productId === productId;
|
|
const matchesPriority = priority === 'all' || task.priority === priority;
|
|
const matchesType = type === 'all' || task.type === type;
|
|
const matchesSource = source === 'all' || task.source === source;
|
|
const matchesUpdatedWindow = updatedWithinDays === 'all'
|
|
? true
|
|
: new Date(task.createdAt).getTime() >= now - updatedWithinDays * 86_400_000;
|
|
|
|
return matchesQuery && matchesStatus && matchesProduct && matchesPriority && matchesType && matchesSource && matchesUpdatedWindow;
|
|
});
|
|
|
|
const priorityRank: Record<HermesPriority, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
|
|
return [...filtered].sort((a, b) => {
|
|
switch (sort) {
|
|
case 'oldest':
|
|
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
case 'priority':
|
|
return priorityRank[a.priority] - priorityRank[b.priority] || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
case 'status':
|
|
return a.status.localeCompare(b.status) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
case 'newest':
|
|
default:
|
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
}
|
|
});
|
|
}
|
|
|
|
export function getHermesTaskById(id: string): HermesTask | undefined {
|
|
return hermesTasks.find((task) => task.id === id);
|
|
}
|
|
|
|
export function getHermesTaskEvents(taskId: string): HermesEvent[] {
|
|
return eventBlueprints.get(taskId) ?? [];
|
|
}
|
|
|
|
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',
|
|
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) {
|
|
case 'high-priority':
|
|
return product.priority === 'P0' || product.priority === 'P1';
|
|
case 'needs-attention':
|
|
return product.needsAttention;
|
|
case 'no-recent-activity':
|
|
return !product.lastHermesActivityAt || new Date(product.lastHermesActivityAt).getTime() < recentCutoff;
|
|
case 'repeated-failures':
|
|
return recentFailedTasks >= 3;
|
|
case 'recently-shipped':
|
|
return recentCompletedTasks > 0 && (product.lastDeploymentAt ? new Date(product.lastDeploymentAt).getTime() >= shippedCutoff : false);
|
|
case 'all':
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
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(instance: HermesInstanceFilter = 'all'): HermesAgentStatus[] {
|
|
return hermesAgentStatuses.filter((agent) => instanceMatches(agent.instanceId, instance));
|
|
}
|
|
|
|
export function getHermesSettings() {
|
|
return hermesSettings;
|
|
}
|
|
|
|
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(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 = tasks.find((task) => task.status === 'failed');
|
|
if (failed) {
|
|
return `Inspect and retry ${failed.title}.`;
|
|
}
|
|
|
|
const staleProduct = products.find((product) => product.needsAttention);
|
|
if (staleProduct) {
|
|
return `Review ${staleProduct.name} because it needs attention.`;
|
|
}
|
|
|
|
return 'No urgent action required. Continue the scheduled execution queue.';
|
|
}
|