Add communication health panel

This commit is contained in:
Brad Groux 2026-06-04 01:33:45 -07:00
parent 4577f67845
commit 322bc1f1f7
6 changed files with 397 additions and 6 deletions

View file

@ -120,6 +120,10 @@ Use the Squad Chat Webhook only when local chat needs to notify an external syst
| Notifications | Stores recipient-specific task/system records, including failure alerts | Optional delivery channel sends externally if configured |
| Broadcasts | Stores durable system-wide messages at `/api/broadcasts` for polling/UI | Agents poll or receive WebSocket updates and mark messages read |
Settings -> Notifications -> Communication Health reports whether each path is configured and whether VK saw the last outbound HTTP result. HTTP success is not visual receipt. For Teams-style workflows, webhook receivers, and OpenClaw gateways, verify the destination manually after VK records a successful delivery.
Generic Squad Chat webhooks send a `squad.message` JSON payload with `event`, `message.id`, `message.agent`, `message.message`, `message.timestamp`, and `isHuman`. When a secret is set, VK signs the request with `X-VK-Signature`. OpenClaw Direct posts a wake payload to `/tools/invoke` with bearer auth. Secrets, bearer tokens, query strings, and webhook paths should stay out of logs, screenshots, and support notes.
## Step-by-Step: Tag Conventions
Use consistent tags so messages are filterable by project or task:

View file

@ -538,12 +538,21 @@ openclaw mcp describe veritas-kanban vk_list_tasks
Squad Chat stores local messages and streams them to connected UI clients. It does not wake an external process by itself. Configure Settings -> Notifications -> Squad Chat Webhook only when you want outbound delivery through a generic webhook or OpenClaw Direct.
If the webhook request returns HTTP success but nothing appears externally, split the failure path:
Use Settings -> Notifications -> Communication Health to separate three different states:
- Configured: VK has a destination URL, channel, or OpenClaw gateway setting.
- HTTP accepted: the last outbound delivery received an HTTP response such as `2xx`.
- Visually verified: a human confirmed the message appeared in the destination. VK does not record this automatically.
Smoke-test the path in this order:
1. Local chat: verify the message exists in the Squad Chat UI or `GET /api/chat/squad`
2. Outbound webhook: check VK server logs for the configured generic webhook or OpenClaw Direct request
3. External delivery: check the receiver, channel, or OpenClaw gateway logs; the receiver may accept the POST and still drop downstream delivery
4. Agent runner: confirm the external orchestrator is configured to wake an agent and post any visible reply back to `/api/chat/squad`
2. Generic webhook: configure a disposable receiver, post a human or agent Squad Chat message, and confirm VK records the outbound HTTP result
3. Teams-style workflow: confirm the receiver or workflow returns HTTP success, then separately verify the message is visible in Teams
4. OpenClaw Direct: confirm the gateway accepts `/tools/invoke`, then confirm the external orchestrator wakes an agent and posts any visible reply back to `/api/chat/squad`
5. Failure alerts: configure Failure Webhook URL if you need immediate external delivery, trigger or use the configured test path, then verify both the HTTP result and the destination-visible alert
Generic Squad Chat webhooks send a `squad.message` payload with `event`, `message.id`, `message.agent`, `message.message`, `message.timestamp`, and `isHuman`. If a webhook secret is configured, VK signs the request with `X-VK-Signature`. The Communication Health panel redacts webhook paths, query strings, secrets, and bearer tokens; avoid pasting full webhook URLs into screenshots or support notes.
### Notifications do not send externally

View file

@ -8,6 +8,65 @@ import { renderWithProviders } from './test-utils';
const mocks = vi.hoisted(() => ({
debouncedUpdate: vi.fn(),
outboundEndpoints: vi.fn(async () => [
{
id: 'squad.webhook',
type: 'squad-webhook',
displayName: 'Squad Chat webhook',
url: 'https://example.com/webhook',
enabled: true,
auth: {
type: 'hmac-sha256',
headerName: 'X-VK-Signature',
secretRef: 'featureSettings.squadWebhook.secret',
hasSecret: true,
},
validation: { valid: true },
updatedAt: '2026-06-04T08:00:00.000Z',
},
{
id: 'notifications.failureAlert',
type: 'failure-alert-webhook',
displayName: 'Failure alert webhook',
url: 'https://example.com/failure-alerts',
enabled: true,
auth: { type: 'none' },
validation: { valid: true },
updatedAt: '2026-06-04T08:00:00.000Z',
},
]),
outboundDeliveries: vi.fn(async () => [
{
id: 'delivery_1',
endpointId: 'squad.webhook',
endpointType: 'squad-webhook',
displayName: 'Squad Chat webhook',
method: 'POST',
sanitizedUrl: 'https://example.com/webhook',
status: 'success',
responseStatus: 202,
responseClass: '2xx',
durationMs: 42,
attempt: 1,
startedAt: '2026-06-04T08:00:00.000Z',
completedAt: '2026-06-04T08:00:01.000Z',
},
{
id: 'delivery_2',
endpointId: 'notifications.failureAlert',
endpointType: 'failure-alert-webhook',
displayName: 'Failure alert webhook',
method: 'POST',
sanitizedUrl: 'https://example.com/failure-alerts',
status: 'failed',
responseStatus: 500,
responseClass: '5xx',
durationMs: 57,
attempt: 1,
startedAt: '2026-06-04T08:00:00.000Z',
completedAt: '2026-06-04T08:00:02.000Z',
},
]),
settings: {
board: {},
tasks: {},
@ -18,6 +77,7 @@ const mocks = vi.hoisted(() => ({
onAgentFailure: true,
onReviewNeeded: true,
channel: '19:test@thread.tacv2',
webhookUrl: 'https://example.com/failure-alerts?token=hidden',
},
squadWebhook: {
enabled: true,
@ -49,6 +109,15 @@ vi.mock('@/hooks/useFeatureSettings', () => ({
}),
}));
vi.mock('@/lib/api', () => ({
api: {
integrations: {
outboundEndpoints: mocks.outboundEndpoints,
outboundDeliveries: mocks.outboundDeliveries,
},
},
}));
vi.mock('@/hooks/useConfig', () => ({
useConfig: () => ({
data: {
@ -83,10 +152,17 @@ describe('Settings tab Mantine controls', () => {
expect(tasksContainer.querySelector('.mantine-Select-root')).toBeDefined();
});
it('renders Notifications text and select controls through direct Mantine primitives', () => {
it('renders Notifications text and select controls through direct Mantine primitives', async () => {
const { container } = renderWithProviders(<NotificationsTab />);
expect(await screen.findByText('Communication Health')).toBeDefined();
expect(screen.getByText('Local Squad Chat')).toBeDefined();
expect(screen.getAllByText('Squad Chat Webhook').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/Visually verified: not recorded in VK/).length).toBeGreaterThan(0);
expect(screen.getByText('X-VK-Signature')).toBeDefined();
expect(await screen.findByText(/success 202/)).toBeDefined();
expect(screen.getByLabelText('Channel')).toBeDefined();
expect(screen.getByLabelText('Failure Webhook URL')).toBeDefined();
expect(screen.getByRole('combobox', { name: 'Mode' })).toBeDefined();
expect(screen.getByLabelText('Webhook URL')).toBeDefined();
expect(screen.getByLabelText('Secret (Optional)')).toBeDefined();

View file

@ -1,11 +1,120 @@
import { Select, TextInput } from '@mantine/core';
import {
Badge,
Code,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { SettingRow, ToggleRow, SectionHeader, SaveIndicator } from '../shared';
import { api, type OutboundDeliveryAttempt, type OutboundEndpointRecord } from '@/lib/api';
type CommunicationState = 'ok' | 'warn' | 'off' | 'unknown';
const STATE_COLORS: Record<CommunicationState, string> = {
ok: 'green',
warn: 'yellow',
off: 'gray',
unknown: 'blue',
};
function redactDestination(value?: string): string {
if (!value?.trim()) return 'not configured';
try {
const parsed = new URL(value);
const hasSensitiveParts = parsed.pathname !== '/' || parsed.search || parsed.hash;
return hasSensitiveParts ? `${parsed.origin}/[redacted]` : parsed.origin;
} catch {
return '[redacted destination]';
}
}
function formatDelivery(delivery?: OutboundDeliveryAttempt): string {
if (!delivery) return 'not recorded';
const status = delivery.responseStatus
? `${delivery.status} ${delivery.responseStatus}`
: delivery.status;
return `${status} at ${new Date(delivery.completedAt).toLocaleString()}`;
}
function notificationDestination(settings: { channel?: string; webhookUrl?: string }): string {
const channel = settings.channel?.trim();
const webhookUrl = settings.webhookUrl?.trim();
if (channel && webhookUrl) return `Teams channel + webhook: ${redactDestination(webhookUrl)}`;
if (channel) return 'Teams channel configured';
if (webhookUrl) return `Webhook: ${redactDestination(webhookUrl)}`;
return 'not configured';
}
function findDelivery(deliveries: OutboundDeliveryAttempt[], endpointId: string) {
return deliveries.find((delivery) => delivery.endpointId === endpointId);
}
function findEndpoint(endpoints: OutboundEndpointRecord[], endpointId: string) {
return endpoints.find((endpoint) => endpoint.id === endpointId);
}
function healthState(enabled: boolean, configured: boolean): CommunicationState {
if (!enabled) return 'off';
return configured ? 'ok' : 'warn';
}
function HealthCard({
title,
state,
label,
detail,
}: {
title: string;
state: CommunicationState;
label: string;
detail: string;
}) {
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" gap="sm">
<Text size="sm" fw={600}>
{title}
</Text>
<Badge color={STATE_COLORS[state]} variant="light" tt="none">
{label}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{detail}
</Text>
</Paper>
);
}
function endpointLabel(endpoint?: OutboundEndpointRecord): string {
if (!endpoint) return 'endpoint pending';
if (!endpoint.validation.valid) return `blocked: ${endpoint.validation.reason ?? 'invalid URL'}`;
return endpoint.enabled ? 'endpoint enabled' : 'endpoint disabled';
}
export function NotificationsTab() {
const { settings } = useFeatureSettings();
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
const { data: outboundEndpoints = [] } = useQuery({
queryKey: ['integrations', 'outbound', 'endpoints'],
queryFn: api.integrations.outboundEndpoints,
staleTime: 30_000,
retry: false,
});
const { data: outboundDeliveries = [] } = useQuery({
queryKey: ['integrations', 'outbound', 'deliveries', 25],
queryFn: () => api.integrations.outboundDeliveries(25),
staleTime: 30_000,
retry: false,
});
const updateNotifications = (key: string, value: any) => {
debouncedUpdate({ notifications: { [key]: value } });
@ -23,9 +132,115 @@ export function NotificationsTab() {
};
const webhookMode = settings.squadWebhook?.mode ?? DEFAULT_FEATURE_SETTINGS.squadWebhook.mode;
const notificationsEnabled =
settings.notifications?.enabled ?? DEFAULT_FEATURE_SETTINGS.notifications.enabled;
const notificationDestinationConfigured = Boolean(
settings.notifications?.channel?.trim() || settings.notifications?.webhookUrl?.trim()
);
const failureWebhookConfigured = Boolean(settings.notifications?.webhookUrl?.trim());
const failureAlertsEnabled =
notificationsEnabled &&
(settings.notifications?.onAgentFailure ??
DEFAULT_FEATURE_SETTINGS.notifications.onAgentFailure);
const squadWebhookEnabled =
settings.squadWebhook?.enabled ?? DEFAULT_FEATURE_SETTINGS.squadWebhook.enabled;
const squadDestination =
webhookMode === 'openclaw'
? settings.squadWebhook?.openclawGatewayUrl
: settings.squadWebhook?.url;
const squadDestinationConfigured = Boolean(squadDestination?.trim());
const squadEndpointId = webhookMode === 'openclaw' ? 'squad.openclawWake' : 'squad.webhook';
const squadEndpoint = findEndpoint(outboundEndpoints, squadEndpointId);
const squadDelivery = findDelivery(outboundDeliveries, squadEndpointId);
const notificationEndpoint = findEndpoint(outboundEndpoints, 'notifications.failureAlert');
const failureDelivery = findDelivery(outboundDeliveries, 'notifications.failureAlert');
return (
<div className="space-y-4">
<div className="space-y-3">
<SectionHeader title="Communication Health" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<HealthCard
title="Local Squad Chat"
state="ok"
label="Working"
detail="Local messages save to the coordination log. External wake or reply behavior requires a configured consumer."
/>
<HealthCard
title="Squad Chat Webhook"
state={healthState(squadWebhookEnabled, squadDestinationConfigured)}
label={
!squadWebhookEnabled
? 'Disabled'
: squadDestinationConfigured
? 'Configured'
: 'Missing destination'
}
detail={`${endpointLabel(squadEndpoint)}. Destination: ${redactDestination(squadDestination)}. HTTP accepted: ${formatDelivery(squadDelivery)}. Visually verified: not recorded in VK.`}
/>
<HealthCard
title="Broad Notifications"
state={healthState(notificationsEnabled, notificationDestinationConfigured)}
label={
!notificationsEnabled
? 'Disabled'
: notificationDestinationConfigured
? 'Configured'
: 'Missing destination'
}
detail={`Destination: ${notificationDestination(settings.notifications ?? {})}. Last test result: not recorded in VK.`}
/>
<HealthCard
title="Failure Alerts"
state={healthState(failureAlertsEnabled, failureWebhookConfigured)}
label={
!failureAlertsEnabled
? 'Disabled'
: failureWebhookConfigured
? 'Webhook configured'
: 'Stored only'
}
detail={`Immediate webhook: ${redactDestination(settings.notifications?.webhookUrl)}. ${endpointLabel(notificationEndpoint)}. HTTP accepted: ${formatDelivery(failureDelivery)}. Visually verified: not recorded in VK.`}
/>
<HealthCard
title="Inbound Wake / Replies"
state={
webhookMode === 'openclaw' && squadWebhookEnabled && squadDestinationConfigured
? 'ok'
: 'off'
}
label={
webhookMode === 'openclaw' && squadWebhookEnabled && squadDestinationConfigured
? 'OpenClaw configured'
: 'Not configured'
}
detail="Generic webhooks are outbound only. OpenClaw Direct can wake a gateway; replies still require the external orchestrator."
/>
<Paper withBorder radius="md" p="sm">
<Stack gap={4}>
<Group justify="space-between" gap="sm">
<Text size="sm" fw={600}>
Payload & Signing
</Text>
<Badge color="blue" variant="light" tt="none">
Redacted
</Badge>
</Group>
<Text size="xs" c="dimmed">
Generic webhooks send <Code>event</Code>, <Code>message.id</Code>,{' '}
<Code>message.agent</Code>, <Code>message.message</Code>,{' '}
<Code>message.timestamp</Code>, and <Code>isHuman</Code>. HMAC signatures use{' '}
<Code>X-VK-Signature</Code> when a secret is set. OpenClaw Direct posts a wake
payload to <Code>/tools/invoke</Code> with bearer auth. Secrets and bearer tokens
are not shown after save.
</Text>
</Stack>
</Paper>
</SimpleGrid>
</div>
<div className="border-t my-6" />
<div className="flex items-center justify-between">
<SectionHeader title="Notifications" onReset={resetNotifications} />
<SaveIndicator isPending={isPending} />
@ -80,6 +295,20 @@ export function NotificationsTab() {
w={192}
/>
</SettingRow>
<SettingRow
label="Failure Webhook URL"
description="Optional Teams or generic webhook for immediate failure alert delivery"
>
<TextInput
value={settings.notifications?.webhookUrl ?? ''}
onChange={(e) => updateNotifications('webhookUrl', e.target.value || undefined)}
placeholder="https://example.com/webhook"
aria-label="Failure Webhook URL"
size="xs"
w={384}
type="url"
/>
</SettingRow>
</>
)}
</div>

View file

@ -22,6 +22,7 @@ import { tracesApi } from './traces';
import { maintenanceApi } from './maintenance';
import { skillCapabilitiesApi } from './skill-capabilities';
import { skillSecurityApi } from './skill-security';
import { integrationsApi } from './integrations';
// Assemble the full API object (matches original structure exactly)
export const api = {
@ -52,6 +53,7 @@ export const api = {
workProducts: workProductsApi,
traces: tracesApi,
maintenance: maintenanceApi,
integrations: integrationsApi,
skillCapabilities: skillCapabilitiesApi,
skillSecurity: skillSecurityApi,
};
@ -69,6 +71,12 @@ export type {
export type { WorkProductExportFormat, WorkProductExportOptions } from './work-products';
export type { TraceStatus } from './traces';
export type { SqlitePortabilityReport } from './maintenance';
export type {
OutboundDeliveryAttempt,
OutboundDeliveryStatus,
OutboundEndpointRecord,
OutboundEndpointType,
} from './integrations';
// Re-export managed list helper
export { managedList } from './managed-list';

View file

@ -0,0 +1,65 @@
import { API_BASE, handleResponse } from './helpers';
export type OutboundEndpointType =
| 'broadcast-webhook'
| 'lifecycle-hook-webhook'
| 'transition-hook-webhook'
| 'policy-webhook'
| 'squad-webhook'
| 'openclaw-wake'
| 'openclaw-gateway'
| 'failure-alert-webhook';
export type OutboundDeliveryStatus = 'success' | 'failed' | 'blocked' | 'timeout' | 'skipped';
export interface OutboundEndpointRecord {
id: string;
type: OutboundEndpointType;
displayName: string;
url: string;
enabled: boolean;
auth: {
type: 'none' | 'hmac-sha256' | 'bearer' | 'custom-header';
secretRef?: string;
headerName?: string;
hasSecret?: boolean;
};
validation: {
valid: boolean;
reason?: string;
};
updatedAt: string;
}
export interface OutboundDeliveryAttempt {
id: string;
endpointId: string;
endpointType: OutboundEndpointType;
displayName: string;
method: string;
sanitizedUrl: string;
status: OutboundDeliveryStatus;
responseStatus?: number;
responseClass?: string;
durationMs: number;
attempt: number;
error?: string;
startedAt: string;
completedAt: string;
}
export const integrationsApi = {
outboundEndpoints: async (): Promise<OutboundEndpointRecord[]> => {
const response = await fetch(`${API_BASE}/integrations/outbound/endpoints`, {
credentials: 'include',
});
return handleResponse<OutboundEndpointRecord[]>(response);
},
outboundDeliveries: async (limit = 25): Promise<OutboundDeliveryAttempt[]> => {
const response = await fetch(`${API_BASE}/integrations/outbound/deliveries?limit=${limit}`, {
credentials: 'include',
});
return handleResponse<OutboundDeliveryAttempt[]>(response);
},
};