feat: add agent health monitoring, audit log, and dead letter queue
heartbeats + dead_letter_queue + audit_log tables in btmsg.db. 15s heartbeat polling in ProjectBox, stale detection, ProjectHeader heart indicator. AuditLogTab for Manager. register_agents_from_groups() with bidirectional contacts and review channel creation.
This commit is contained in:
parent
b2932273ba
commit
5c31668760
10 changed files with 1624 additions and 4 deletions
|
|
@ -3,6 +3,8 @@
|
|||
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
|
||||
import { generateAgentPrompt } from '../../utils/agent-prompts';
|
||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { logAuditEvent } from '../../adapters/audit-bridge';
|
||||
import type { AgentId } from '../../types/ids';
|
||||
import {
|
||||
loadProjectAgentState,
|
||||
loadAgentMessages,
|
||||
|
|
@ -75,6 +77,12 @@
|
|||
? '[Context Refresh] Review your role and available tools above. Check your inbox with `btmsg inbox` and review the task board with `bttask board`.'
|
||||
: '[Context Refresh] Review the instructions above and continue your work.';
|
||||
contextRefreshPrompt = refreshMsg;
|
||||
// Audit: log prompt injection event
|
||||
logAuditEvent(
|
||||
project.id as unknown as AgentId,
|
||||
'prompt_injection',
|
||||
`Context refresh triggered after ${Math.floor(elapsed / 60_000)} min idle`,
|
||||
).catch(() => {});
|
||||
}
|
||||
}, 60_000); // Check every minute
|
||||
}
|
||||
|
|
|
|||
300
v2/src/lib/components/Workspace/AuditLogTab.svelte
Normal file
300
v2/src/lib/components/Workspace/AuditLogTab.svelte
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getAuditLog, type AuditEntry, type AuditEventType } from '../../adapters/audit-bridge';
|
||||
import { getGroupAgents, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
||||
import type { GroupId, AgentId } from '../../types/ids';
|
||||
|
||||
interface Props {
|
||||
groupId: GroupId;
|
||||
}
|
||||
|
||||
let { groupId }: Props = $props();
|
||||
|
||||
const EVENT_TYPES: AuditEventType[] = [
|
||||
'prompt_injection',
|
||||
'wake_event',
|
||||
'btmsg_sent',
|
||||
'btmsg_received',
|
||||
'status_change',
|
||||
'heartbeat_missed',
|
||||
'dead_letter',
|
||||
];
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
prompt_injection: 'var(--ctp-mauve)',
|
||||
wake_event: 'var(--ctp-peach)',
|
||||
btmsg_sent: 'var(--ctp-blue)',
|
||||
btmsg_received: 'var(--ctp-teal)',
|
||||
status_change: 'var(--ctp-green)',
|
||||
heartbeat_missed: 'var(--ctp-yellow)',
|
||||
dead_letter: 'var(--ctp-red)',
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
manager: 'var(--ctp-mauve)',
|
||||
architect: 'var(--ctp-blue)',
|
||||
tester: 'var(--ctp-green)',
|
||||
reviewer: 'var(--ctp-peach)',
|
||||
project: 'var(--ctp-text)',
|
||||
admin: 'var(--ctp-overlay1)',
|
||||
};
|
||||
|
||||
let entries = $state<AuditEntry[]>([]);
|
||||
let agents = $state<BtmsgAgent[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Filters
|
||||
let enabledTypes = $state<Set<string>>(new Set(EVENT_TYPES));
|
||||
let selectedAgent = $state<string>('all');
|
||||
|
||||
let filteredEntries = $derived.by(() => {
|
||||
return entries
|
||||
.filter(e => enabledTypes.has(e.eventType))
|
||||
.filter(e => selectedAgent === 'all' || e.agentId === selectedAgent)
|
||||
.slice(0, 200);
|
||||
});
|
||||
|
||||
function agentName(agentId: string): string {
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
return agent?.name ?? agentId;
|
||||
}
|
||||
|
||||
function agentRole(agentId: string): string {
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
return agent?.role ?? 'unknown';
|
||||
}
|
||||
|
||||
function toggleType(type: string) {
|
||||
const next = new Set(enabledTypes);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
enabledTypes = next;
|
||||
}
|
||||
|
||||
function formatTime(createdAt: string): string {
|
||||
try {
|
||||
const d = new Date(createdAt + 'Z');
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [auditData, agentData] = await Promise.all([
|
||||
getAuditLog(groupId, 200, 0),
|
||||
getGroupAgents(groupId),
|
||||
]);
|
||||
entries = auditData;
|
||||
agents = agentData;
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchData();
|
||||
pollTimer = setInterval(fetchData, 5_000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="audit-log-tab">
|
||||
<div class="audit-toolbar">
|
||||
<div class="filter-types">
|
||||
{#each EVENT_TYPES as type}
|
||||
<button
|
||||
class="type-chip"
|
||||
class:active={enabledTypes.has(type)}
|
||||
style="--chip-color: {EVENT_COLORS[type] ?? 'var(--ctp-overlay1)'}"
|
||||
onclick={() => toggleType(type)}
|
||||
>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<select
|
||||
class="agent-select"
|
||||
bind:value={selectedAgent}
|
||||
>
|
||||
<option value="all">All agents</option>
|
||||
{#each agents.filter(a => a.id !== 'admin') as agent}
|
||||
<option value={agent.id}>{agent.name} ({agent.role})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="audit-entries">
|
||||
{#if loading}
|
||||
<div class="audit-empty">Loading audit log...</div>
|
||||
{:else if error}
|
||||
<div class="audit-empty audit-error">Error: {error}</div>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<div class="audit-empty">No audit events yet</div>
|
||||
{:else}
|
||||
{#each filteredEntries as entry (entry.id)}
|
||||
<div class="audit-entry">
|
||||
<span class="entry-time">{formatTime(entry.createdAt)}</span>
|
||||
<span
|
||||
class="entry-agent"
|
||||
style="color: {ROLE_COLORS[agentRole(entry.agentId)] ?? 'var(--ctp-text)'}"
|
||||
>
|
||||
{agentName(entry.agentId)}
|
||||
</span>
|
||||
<span
|
||||
class="entry-type"
|
||||
style="--badge-color: {EVENT_COLORS[entry.eventType] ?? 'var(--ctp-overlay1)'}"
|
||||
>
|
||||
{entry.eventType.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span class="entry-detail">{entry.detail}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.audit-log-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audit-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-types {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.type-chip {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid var(--chip-color);
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: var(--chip-color);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
font-family: inherit;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.type-chip.active {
|
||||
background: color-mix(in srgb, var(--chip-color) 15%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.type-chip:hover {
|
||||
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.audit-entries {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.audit-error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.audit-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.audit-entry:hover {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-agent {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.entry-type {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
border-radius: 0.1875rem;
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
color: var(--badge-color);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.entry-detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ctp-subtext0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,7 +15,11 @@
|
|||
import ArchitectureTab from './ArchitectureTab.svelte';
|
||||
import TestingTab from './TestingTab.svelte';
|
||||
import MetricsPanel from './MetricsPanel.svelte';
|
||||
import { getTerminalTabs, getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import AuditLogTab from './AuditLogTab.svelte';
|
||||
import {
|
||||
getTerminalTabs, getActiveGroup,
|
||||
getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle,
|
||||
} from '../../stores/workspace.svelte';
|
||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
||||
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
||||
import { recordExternalWrite } from '../../stores/conflicts.svelte';
|
||||
|
|
@ -24,6 +28,7 @@
|
|||
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
|
||||
import { setReviewQueueDepth } from '../../stores/health.svelte';
|
||||
import { reviewQueueCount } from '../../adapters/bttask-bridge';
|
||||
import { getStaleAgents } from '../../adapters/btmsg-bridge';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
|
|
@ -38,13 +43,16 @@
|
|||
let mainSessionId = $state<string | null>(null);
|
||||
let terminalExpanded = $state(false);
|
||||
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'metrics' | 'tasks' | 'architecture' | 'selenium' | 'tests';
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'metrics' | 'tasks' | 'architecture' | 'selenium' | 'tests' | 'audit';
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
let agentRole = $derived(project.agentRole);
|
||||
let isAgent = $derived(project.isAgent ?? false);
|
||||
|
||||
// Heartbeat status for Tier 1 agents
|
||||
let heartbeatStatus = $state<'healthy' | 'stale' | 'dead' | null>(null);
|
||||
|
||||
// PERSISTED-LAZY: track which tabs have been activated at least once
|
||||
let everActivated = $state<Record<string, boolean>>({});
|
||||
|
||||
|
|
@ -52,6 +60,23 @@
|
|||
let projectHealth = $derived(getProjectHealth(project.id));
|
||||
let termTabCount = $derived(termTabs.length);
|
||||
|
||||
// Focus flash animation (triggered by keyboard quick-jump)
|
||||
let flashProjectId = $derived(getFocusFlashProjectId());
|
||||
let isFlashing = $derived(flashProjectId === project.id);
|
||||
|
||||
// Tab name -> index mapping for keyboard switching
|
||||
const TAB_INDEX_MAP: ProjectTab[] = [
|
||||
'model', // 1
|
||||
'docs', // 2
|
||||
'context', // 3
|
||||
'files', // 4
|
||||
'ssh', // 5
|
||||
'memories', // 6
|
||||
'metrics', // 7
|
||||
'tasks', // 8
|
||||
'architecture',// 9
|
||||
];
|
||||
|
||||
/** Activate a tab — for lazy tabs, mark as ever-activated */
|
||||
function switchTab(tab: ProjectTab) {
|
||||
activeTab = tab;
|
||||
|
|
@ -64,6 +89,23 @@
|
|||
terminalExpanded = !terminalExpanded;
|
||||
}
|
||||
|
||||
// Listen for keyboard-driven tab switches
|
||||
$effect(() => {
|
||||
const unsubTab = onProjectTabSwitch((pid, tabIndex) => {
|
||||
if (pid !== project.id) return;
|
||||
const tabName = TAB_INDEX_MAP[tabIndex - 1];
|
||||
if (tabName) switchTab(tabName);
|
||||
});
|
||||
const unsubTerm = onTerminalToggle((pid) => {
|
||||
if (pid !== project.id) return;
|
||||
terminalExpanded = !terminalExpanded;
|
||||
});
|
||||
return () => {
|
||||
unsubTab();
|
||||
unsubTerm();
|
||||
};
|
||||
});
|
||||
|
||||
// Sync per-project stall threshold to health store
|
||||
$effect(() => {
|
||||
setStallThreshold(project.id, project.stallThresholdMin ?? null);
|
||||
|
|
@ -112,6 +154,35 @@
|
|||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// Heartbeat monitoring for Tier 1 agents
|
||||
$effect(() => {
|
||||
if (!project.isAgent) return;
|
||||
const groupId = activeGroup?.id;
|
||||
if (!groupId) return;
|
||||
|
||||
const pollHeartbeat = () => {
|
||||
// 300s = healthy threshold, 600s = dead threshold
|
||||
getStaleAgents(groupId as unknown as GroupId, 300)
|
||||
.then(staleIds => {
|
||||
if (staleIds.includes(project.id)) {
|
||||
// Check if truly dead (>10 min)
|
||||
getStaleAgents(groupId as unknown as GroupId, 600)
|
||||
.then(deadIds => {
|
||||
heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale';
|
||||
})
|
||||
.catch(() => { heartbeatStatus = 'stale'; });
|
||||
} else {
|
||||
heartbeatStatus = 'healthy';
|
||||
}
|
||||
})
|
||||
.catch(() => { heartbeatStatus = null; });
|
||||
};
|
||||
|
||||
pollHeartbeat();
|
||||
const timer = setInterval(pollHeartbeat, 15_000); // 15s poll
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// S-1 Phase 2: start filesystem watcher for this project's CWD
|
||||
$effect(() => {
|
||||
const cwd = project.cwd;
|
||||
|
|
@ -162,6 +233,7 @@
|
|||
<div
|
||||
class="project-box"
|
||||
class:active
|
||||
class:focus-flash={isFlashing}
|
||||
style="--accent: var({accentVar})"
|
||||
data-testid="project-box"
|
||||
data-project-id={project.id}
|
||||
|
|
@ -171,6 +243,7 @@
|
|||
{slotIndex}
|
||||
{active}
|
||||
health={projectHealth}
|
||||
{heartbeatStatus}
|
||||
onclick={onactivate}
|
||||
/>
|
||||
|
||||
|
|
@ -223,6 +296,9 @@
|
|||
<button class="ptab ptab-role" class:active={activeTab === 'selenium'} onclick={() => switchTab('selenium')}>Selenium</button>
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'tests'} onclick={() => switchTab('tests')}>Tests</button>
|
||||
{/if}
|
||||
{#if isAgent && agentRole === 'manager'}
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'audit'} onclick={() => switchTab('audit')}>Audit</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="project-content-area">
|
||||
|
|
@ -281,6 +357,11 @@
|
|||
<TestingTab cwd={project.cwd} mode="tests" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['audit'] && activeGroup}
|
||||
<div class="content-pane" style:display={activeTab === 'audit' ? 'flex' : 'none'}>
|
||||
<AuditLogTab groupId={activeGroup.id} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="terminal-section" style:display={activeTab === 'model' ? 'flex' : 'none'}>
|
||||
|
|
@ -321,6 +402,21 @@
|
|||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.project-box.focus-flash {
|
||||
animation: focus-flash 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes focus-flash {
|
||||
0% {
|
||||
border-color: var(--ctp-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ctp-blue) 40%, transparent);
|
||||
}
|
||||
100% {
|
||||
border-color: var(--accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.project-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
slotIndex: number;
|
||||
active: boolean;
|
||||
health: ProjectHealth | null;
|
||||
/** Heartbeat status for Tier 1 agents: 'healthy' | 'stale' | 'dead' | null */
|
||||
heartbeatStatus?: 'healthy' | 'stale' | 'dead' | null;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { project, slotIndex, active, health, onclick }: Props = $props();
|
||||
let { project, slotIndex, active, health, heartbeatStatus = null, onclick }: Props = $props();
|
||||
|
||||
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
|
||||
|
||||
|
|
@ -82,6 +84,18 @@
|
|||
<span class="project-id">({project.identifier})</span>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
{#if heartbeatStatus && project.isAgent}
|
||||
<span
|
||||
class="info-heartbeat"
|
||||
class:hb-healthy={heartbeatStatus === 'healthy'}
|
||||
class:hb-stale={heartbeatStatus === 'stale'}
|
||||
class:hb-dead={heartbeatStatus === 'dead'}
|
||||
title={heartbeatStatus === 'healthy' ? 'Agent healthy' : heartbeatStatus === 'stale' ? 'Agent stale — no heartbeat recently' : 'Agent dead — no heartbeat'}
|
||||
>
|
||||
{heartbeatStatus === 'healthy' ? '♥' : heartbeatStatus === 'stale' ? '♥' : '♡'}
|
||||
</span>
|
||||
<span class="info-sep">·</span>
|
||||
{/if}
|
||||
{#if health && health.externalConflictCount > 0}
|
||||
<button
|
||||
class="info-conflict info-conflict-external"
|
||||
|
|
@ -273,6 +287,25 @@
|
|||
background: color-mix(in srgb, var(--ctp-peach) 25%, transparent);
|
||||
}
|
||||
|
||||
.info-heartbeat {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hb-healthy {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.hb-stale {
|
||||
color: var(--ctp-yellow);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hb-dead {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.info-profile {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-blue);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue