mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
fix: improve the workflow ai
This commit is contained in:
@@ -5,6 +5,11 @@ export type AiSettings = {
|
||||
enabled: boolean;
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
autopilotEnabled: boolean;
|
||||
autopilotFollowupMinutes: number;
|
||||
autopilotMaxUpdates: number;
|
||||
autopilotAdvanceStatus: boolean;
|
||||
autopilotDegradedIncidents: boolean;
|
||||
apiKeySet: boolean;
|
||||
apiKeyPreview: string | null;
|
||||
createdAt: string | null;
|
||||
@@ -16,6 +21,35 @@ export type AiSettingsInput = {
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
apiKey?: string | null;
|
||||
autopilotEnabled: boolean;
|
||||
autopilotFollowupMinutes: number;
|
||||
autopilotMaxUpdates: number;
|
||||
autopilotAdvanceStatus: boolean;
|
||||
autopilotDegradedIncidents: boolean;
|
||||
};
|
||||
|
||||
export type AiEvent = {
|
||||
id: number;
|
||||
kind: string;
|
||||
incidentId: number | null;
|
||||
monitorId: number | null;
|
||||
model: string | null;
|
||||
outcome: string;
|
||||
reason: string | null;
|
||||
latencyMs: number | null;
|
||||
promptTokens: number | null;
|
||||
completionTokens: number | null;
|
||||
contextPreview: string | null;
|
||||
outputPreview: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AiEventSummary = {
|
||||
total: number;
|
||||
ok: number;
|
||||
averageLatencyMs: number | null;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
};
|
||||
|
||||
export function getAiSettings(signal?: AbortSignal) {
|
||||
@@ -32,3 +66,10 @@ export function updateAiSettings(input: AiSettingsInput) {
|
||||
export function testAiSettings() {
|
||||
return postJson<{ ok: true; message: string }>('/api/settings/ai/test');
|
||||
}
|
||||
|
||||
export function getAiEvents(signal?: AbortSignal) {
|
||||
return getJson<{ events: AiEvent[]; summary: AiEventSummary }>('/api/settings/ai/events?limit=50', {
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
|
||||
import { useAiEventsQuery } from '../../queries/settings';
|
||||
|
||||
function formatTokens(value: number) {
|
||||
return new Intl.NumberFormat(undefined, { notation: value >= 10_000 ? 'compact' : 'standard' }).format(value);
|
||||
}
|
||||
|
||||
export function AiActivityPanel() {
|
||||
const query = useAiEventsQuery();
|
||||
if (query.isPending) return <div className="table-empty">Loading AI activity…</div>;
|
||||
if (query.isError) {
|
||||
return (
|
||||
<Empty variant="error" className="m-6">
|
||||
<EmptyTitle>Unable to load AI activity</EmptyTitle>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
const { events, summary } = query.data;
|
||||
const tokenTotal = summary.promptTokens + summary.completionTokens;
|
||||
return (
|
||||
<div className="ai-activity-panel">
|
||||
<div className="ai-activity-stats">
|
||||
<div>
|
||||
<strong>{summary.total ? Math.round((summary.ok / summary.total) * 100) : 0}%</strong>
|
||||
<span>Success rate</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{summary.total}</strong>
|
||||
<span>Calls · 7 days</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{summary.averageLatencyMs === null ? '—' : `${summary.averageLatencyMs} ms`}</strong>
|
||||
<span>Average latency</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatTokens(tokenTotal)}</strong>
|
||||
<span>Total tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<Empty className="channel-empty">
|
||||
<EmptyTitle>No AI activity yet</EmptyTitle>
|
||||
<EmptyDescription>Attempts will appear here after AI generation or autopilot runs.</EmptyDescription>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="ai-activity-history" aria-label="AI activity history">
|
||||
{events.map((event) => (
|
||||
<div className="ai-activity-row" key={event.id}>
|
||||
<Badge variant={event.outcome === 'ok' ? 'online' : event.outcome.startsWith('skipped') ? 'pending' : 'offline'}>
|
||||
{event.outcome.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<strong>{event.kind.replaceAll('_', ' ')}</strong>
|
||||
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
||||
<span>{event.reason ?? event.model ?? '—'}</span>
|
||||
<small>
|
||||
{event.latencyMs === null ? '—' : `${event.latencyMs} ms`} ·{' '}
|
||||
{formatTokens((event.promptTokens ?? 0) + (event.completionTokens ?? 0))} tokens
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { ArrowLeft, BellRing, Sparkles, Wrench } from 'lucide-react';
|
||||
import { Activity, ArrowLeft, BellRing, Sparkles, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Empty, EmptyTitle } from '@/components/ui/empty';
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { AiSettings } from '../api/settings';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
import { AiActivityPanel } from '../components/settings/AiActivityPanel';
|
||||
import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel';
|
||||
import { NotificationChannelsPanel } from '../components/settings/NotificationChannelsPanel';
|
||||
import { navigate } from '../lib/router';
|
||||
@@ -17,6 +18,11 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState(settings.model ?? 'gpt-4o-mini');
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [autopilotEnabled, setAutopilotEnabled] = useState(settings.autopilotEnabled);
|
||||
const [followupMinutes, setFollowupMinutes] = useState(settings.autopilotFollowupMinutes);
|
||||
const [maxUpdates, setMaxUpdates] = useState(settings.autopilotMaxUpdates);
|
||||
const [advanceStatus, setAdvanceStatus] = useState(settings.autopilotAdvanceStatus);
|
||||
const [degradedIncidents, setDegradedIncidents] = useState(settings.autopilotDegradedIncidents);
|
||||
const updateMutation = useUpdateAiSettingsMutation();
|
||||
const testMutation = useTestAiSettingsMutation();
|
||||
|
||||
@@ -27,6 +33,11 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) {
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
model: model.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
autopilotEnabled,
|
||||
autopilotFollowupMinutes: followupMinutes,
|
||||
autopilotMaxUpdates: maxUpdates,
|
||||
autopilotAdvanceStatus: advanceStatus,
|
||||
autopilotDegradedIncidents: degradedIncidents,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +76,54 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) {
|
||||
<small>Generate one sanitized public update when an incident opens.</small>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className="autopilot-settings">
|
||||
<legend>Autopilot</legend>
|
||||
<div className="settings-toggle">
|
||||
<Switch id="autopilot-enabled" checked={autopilotEnabled} onCheckedChange={setAutopilotEnabled} />
|
||||
<label htmlFor="autopilot-enabled">
|
||||
<strong>Enable incident autopilot</strong>
|
||||
<small>Write sanitized opening, follow-up, and resolution updates without sending extra alerts.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="autopilot-number-fields">
|
||||
<label className="field" htmlFor="autopilot-cadence">
|
||||
<span>Initial follow-up cadence (minutes)</span>
|
||||
<Input
|
||||
id="autopilot-cadence"
|
||||
type="number"
|
||||
min={5}
|
||||
max={240}
|
||||
value={followupMinutes}
|
||||
onChange={(event) => setFollowupMinutes(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field" htmlFor="autopilot-max-updates">
|
||||
<span>Maximum automatic updates</span>
|
||||
<Input
|
||||
id="autopilot-max-updates"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={maxUpdates}
|
||||
onChange={(event) => setMaxUpdates(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle">
|
||||
<Switch id="autopilot-advance-status" checked={advanceStatus} onCheckedChange={setAdvanceStatus} />
|
||||
<label htmlFor="autopilot-advance-status">
|
||||
<strong>Advance incident status</strong>
|
||||
<small>Use objective check patterns to move between investigating, identified, and monitoring.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle">
|
||||
<Switch id="autopilot-degraded" checked={degradedIncidents} onCheckedChange={setDegradedIncidents} />
|
||||
<label htmlFor="autopilot-degraded">
|
||||
<strong>Open degraded incidents</strong>
|
||||
<small>Publish performance degradation incidents. Keep disabled to avoid public noise.</small>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="settings-actions">
|
||||
<Button
|
||||
variant="unstyled"
|
||||
@@ -102,6 +161,20 @@ export function SettingsPage() {
|
||||
<h1>Notifications, AI & maintenance</h1>
|
||||
<p>Configure incident alerts, visitor-friendly updates, and planned downtime from one place.</p>
|
||||
</section>
|
||||
<Card asChild>
|
||||
<section className="settings-card">
|
||||
<div className="settings-card-intro">
|
||||
<span>
|
||||
<Activity />
|
||||
</span>
|
||||
<div>
|
||||
<h2>AI activity</h2>
|
||||
<p>Audit model calls, sanitizer rejections, latency, and token usage from the last seven days.</p>
|
||||
</div>
|
||||
</div>
|
||||
<AiActivityPanel />
|
||||
</section>
|
||||
</Card>
|
||||
<Card asChild>
|
||||
<section className="settings-card">
|
||||
<div className="settings-card-intro">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { getAiSettings, testAiSettings, updateAiSettings, type AiSettingsInput } from '../api/settings';
|
||||
import { getAiEvents, getAiSettings, testAiSettings, updateAiSettings, type AiSettingsInput } from '../api/settings';
|
||||
import { queryClient } from '../lib/query-client';
|
||||
|
||||
export const settingsKeys = {
|
||||
all: ['settings'] as const,
|
||||
ai: () => [...settingsKeys.all, 'ai'] as const,
|
||||
aiEvents: () => [...settingsKeys.all, 'ai-events'] as const,
|
||||
};
|
||||
|
||||
export function useAiSettingsQuery() {
|
||||
@@ -24,3 +25,10 @@ export function useUpdateAiSettingsMutation() {
|
||||
export function useTestAiSettingsMutation() {
|
||||
return useMutation({ mutationFn: testAiSettings });
|
||||
}
|
||||
|
||||
export function useAiEventsQuery() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.aiEvents(),
|
||||
queryFn: ({ signal }) => getAiEvents(signal),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1626,6 +1626,81 @@ button {
|
||||
.channel-history-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
.autopilot-settings {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin: 4px 0;
|
||||
padding: 20px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
background: #fafcfb;
|
||||
}
|
||||
.autopilot-settings legend {
|
||||
padding: 0 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.autopilot-number-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.ai-activity-panel {
|
||||
padding: 22px 26px 26px;
|
||||
}
|
||||
.ai-activity-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.ai-activity-stats > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 7px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.ai-activity-stats strong {
|
||||
font-size: 19px;
|
||||
}
|
||||
.ai-activity-stats span,
|
||||
.ai-activity-row span,
|
||||
.ai-activity-row small {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ai-activity-history {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin-top: 18px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #e6e6e6;
|
||||
}
|
||||
.ai-activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px minmax(110px, 0.8fr) minmax(145px, 1fr) minmax(130px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
background: #fff;
|
||||
}
|
||||
.ai-activity-row strong {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.ai-activity-stats,
|
||||
.autopilot-number-fields {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.ai-activity-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.channel-history-state {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const AUTOPILOT_STATUS_GUIDANCE = {
|
||||
investigating: 'State that automated monitoring continues to observe the disruption. Do not imply that the cause is known.',
|
||||
identified:
|
||||
'State only that monitoring has consistently observed the same failure pattern. Do not claim a root cause or human diagnosis.',
|
||||
monitoring:
|
||||
'State that service responses have recently improved and automated monitoring is checking that recovery remains stable. Do not claim a fix was applied.',
|
||||
} as const;
|
||||
|
||||
const PUBLIC_RULES = [
|
||||
'Write for non-technical visitors in calm, factual language.',
|
||||
'Never include URLs, hostnames, domains, IP addresses, ports, paths, HTTP status codes, error codes, or stack traces.',
|
||||
'Never invent a root cause, a human action, or an estimated recovery time.',
|
||||
'Output only the requested fields.',
|
||||
].join('\n');
|
||||
|
||||
export const INCIDENT_OPEN_SYSTEM_PROMPT = [
|
||||
'Create a concise public incident title and opening update.',
|
||||
'Use exactly this format: TITLE: one short title\nBODY: two short sentences under 240 characters total.',
|
||||
PUBLIC_RULES,
|
||||
].join('\n');
|
||||
|
||||
export const INCIDENT_FOLLOWUP_SYSTEM_PROMPT = [
|
||||
'Write one short public follow-up update under 240 characters.',
|
||||
'Describe only the observed visitor impact and current automated-monitoring state.',
|
||||
PUBLIC_RULES,
|
||||
].join('\n');
|
||||
|
||||
export const INCIDENT_RESOLVE_SYSTEM_PROMPT = [
|
||||
'Write one short public resolution update under 240 characters.',
|
||||
'Say that service has recovered, mention the supplied approximate duration, and say monitoring confirms normal operation.',
|
||||
'Do not claim that a fix was applied.',
|
||||
PUBLIC_RULES,
|
||||
].join('\n');
|
||||
+56
-12
@@ -6,14 +6,29 @@ export type CompletionSettings = {
|
||||
|
||||
type CompletionBody = {
|
||||
choices?: Array<{ message?: { content?: unknown } }>;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
|
||||
export async function requestCompletion(
|
||||
export type CompletionResult = {
|
||||
content: string | null;
|
||||
latencyMs: number;
|
||||
promptTokens: number | null;
|
||||
completionTokens: number | null;
|
||||
failure: string | null;
|
||||
};
|
||||
|
||||
function failureMessage(error: unknown): string {
|
||||
if (error instanceof DOMException && error.name === 'TimeoutError') return 'timeout';
|
||||
return (error instanceof Error ? error.message : String(error)).slice(0, 200);
|
||||
}
|
||||
|
||||
export async function requestCompletionDetailed(
|
||||
settings: CompletionSettings,
|
||||
system: string,
|
||||
user: string,
|
||||
maxTokens = 160,
|
||||
): Promise<string | null> {
|
||||
): Promise<CompletionResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
@@ -35,23 +50,52 @@ export async function requestCompletion(
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
console.warn(JSON.stringify({ message: 'AI completion returned an error', status: response.status }));
|
||||
return null;
|
||||
return {
|
||||
content: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
promptTokens: null,
|
||||
completionTokens: null,
|
||||
failure: `http_${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const body = (await response.json()) as CompletionBody;
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
console.warn(JSON.stringify({ message: 'AI completion returned malformed content' }));
|
||||
return null;
|
||||
return {
|
||||
content: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
promptTokens: body.usage?.prompt_tokens ?? null,
|
||||
completionTokens: body.usage?.completion_tokens ?? null,
|
||||
failure: 'malformed',
|
||||
};
|
||||
}
|
||||
return content;
|
||||
return {
|
||||
content,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
promptTokens: body.usage?.prompt_tokens ?? null,
|
||||
completionTokens: body.usage?.completion_tokens ?? null,
|
||||
failure: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: 'AI completion failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
const failure = failureMessage(error);
|
||||
console.warn(JSON.stringify({ message: 'AI completion failed', error: failure }));
|
||||
return {
|
||||
content: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
promptTokens: null,
|
||||
completionTokens: null,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCompletion(
|
||||
settings: CompletionSettings,
|
||||
system: string,
|
||||
user: string,
|
||||
maxTokens = 160,
|
||||
): Promise<string | null> {
|
||||
return (await requestCompletionDetailed(settings, system, user, maxTokens)).content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getDb } from '../db/client';
|
||||
import { aiEvents } from '../db/schema';
|
||||
|
||||
export type AiEventKind = 'incident_open' | 'incident_followup' | 'incident_resolve' | 'degraded_open' | 'manual_draft' | 'settings_test';
|
||||
export type AiEventOutcome = 'ok' | 'rejected' | 'failed' | 'skipped_budget' | 'skipped_standdown';
|
||||
|
||||
export type AiEventInput = {
|
||||
kind: AiEventKind;
|
||||
incidentId?: number | null;
|
||||
monitorId?: number | null;
|
||||
model?: string | null;
|
||||
outcome: AiEventOutcome;
|
||||
reason?: string | null;
|
||||
latencyMs?: number | null;
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
contextPreview?: string | null;
|
||||
outputPreview?: string | null;
|
||||
};
|
||||
|
||||
function preview(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value
|
||||
.replace(/https?:\/\/\S+/gi, '[redacted-url]')
|
||||
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[redacted-ip]')
|
||||
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi, '[redacted-host]')
|
||||
.replace(/\b(?:sk|pk|rk)-[a-z0-9_-]+\b/gi, '[redacted-key]')
|
||||
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
export async function recordAiEvent(env: Env, input: AiEventInput): Promise<void> {
|
||||
try {
|
||||
await getDb(env)
|
||||
.insert(aiEvents)
|
||||
.values({
|
||||
kind: input.kind,
|
||||
incidentId: input.incidentId ?? null,
|
||||
monitorId: input.monitorId ?? null,
|
||||
model: input.model ?? null,
|
||||
outcome: input.outcome,
|
||||
reason: preview(input.reason)?.slice(0, 200) ?? null,
|
||||
latencyMs: input.latencyMs ?? null,
|
||||
promptTokens: input.promptTokens ?? null,
|
||||
completionTokens: input.completionTokens ?? null,
|
||||
contextPreview: preview(input.contextPreview),
|
||||
outputPreview: preview(input.outputPreview),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ message: 'AI event recording failed', error: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ export const INCIDENT_REASSURANCE =
|
||||
|
||||
export const RECOVERY_UPDATE_BODY = 'The service has recovered and is responding normally again.';
|
||||
export const DEGRADED_MESSAGE = 'This service is responding more slowly than usual and some pages may take longer to load.';
|
||||
export const DEGRADED_RECOVERY_UPDATE_BODY = 'Response times have returned to normal and the service is operating normally.';
|
||||
export const DEGRADED_SUPERSEDED_UPDATE_BODY =
|
||||
'The performance issue developed into a service disruption and is tracked in a new incident.';
|
||||
|
||||
/** Plain-language, non-technical description of the impact, used when no AI message is available. */
|
||||
export function describeFailure(statusCode: number | null): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, desc, eq, gte, sql } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import type { Database } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents } from '../db/schema';
|
||||
import { classifyFailure, humanizeDuration, humanizeInterval } from '../lib/humanize';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
@@ -14,55 +15,6 @@ const FLAP_WINDOW = 10;
|
||||
* for the model only - it may name HTTP codes and transport errors here because
|
||||
* the prompt is responsible for translating them into visitor-facing language.
|
||||
*/
|
||||
function classifyFailure(monitor: Monitor, result: CheckResult): string {
|
||||
if (result.statusCode === null) {
|
||||
const error = (result.error ?? '').toLowerCase();
|
||||
if (/tim(?:e|ed)\s?out|timeout|deadline|aborted/.test(error)) {
|
||||
return 'Request timed out - no response came back before the timeout limit';
|
||||
}
|
||||
if (/getaddrinfo|enotfound|dns|name not resolved|could not resolve|eai_again/.test(error)) {
|
||||
return 'DNS lookup failed - the hostname could not be resolved to an address';
|
||||
}
|
||||
if (/certificate|cert(?:\s|_)|self[- ]signed|tls|ssl|handshake|err_cert/.test(error)) {
|
||||
return 'TLS/SSL failure - the certificate is invalid, expired, or untrusted';
|
||||
}
|
||||
if (/econnrefused|connection refused|refused to connect/.test(error)) {
|
||||
return 'Connection refused - nothing is accepting connections at that address';
|
||||
}
|
||||
if (/econnreset|connection reset|socket hang up|premature close/.test(error)) {
|
||||
return 'Connection dropped before any response was returned';
|
||||
}
|
||||
if (/ehostunreach|enetunreach|network is unreachable|no route to host/.test(error)) {
|
||||
return 'Network unreachable - the host could not be contacted at all';
|
||||
}
|
||||
return 'No HTTP response was received from the endpoint';
|
||||
}
|
||||
|
||||
const code = result.statusCode;
|
||||
if (code >= 500) return `Server error - the endpoint answered with HTTP ${code}`;
|
||||
if (code === 429) return 'The endpoint is rate limiting - HTTP 429 Too Many Requests';
|
||||
if (code === 401 || code === 403) return `The endpoint rejected the health check as unauthorized - HTTP ${code}`;
|
||||
if (code === 404) return 'The health-check path returned HTTP 404 Not Found';
|
||||
if (code >= 400) return `Client error - the endpoint answered with HTTP ${code}`;
|
||||
if (code >= 300) return `Unexpected redirect - the endpoint answered with HTTP ${code}`;
|
||||
return `The endpoint answered with HTTP ${code}, but HTTP ${monitor.expectedStatus} was expected`;
|
||||
}
|
||||
|
||||
function humanizeInterval(seconds: number): string {
|
||||
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`;
|
||||
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`;
|
||||
return `${seconds} s`;
|
||||
}
|
||||
|
||||
function humanizeDuration(ms: number): string {
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return 'under a minute';
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings } from '../db/schema';
|
||||
import { requestCompletion } from './client';
|
||||
import { requestCompletionDetailed } from './client';
|
||||
import { recordAiEvent } from './events';
|
||||
import { sanitizePublicText } from './sanitize';
|
||||
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
@@ -63,20 +64,54 @@ export async function draftIncidentUpdate(env: Env, input: DraftInput): Promise<
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const completion = await requestCompletion(
|
||||
const result = await requestCompletionDetailed(
|
||||
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
|
||||
INCIDENT_DRAFT_SYSTEM_PROMPT,
|
||||
context,
|
||||
320,
|
||||
);
|
||||
if (!completion) throw new IncidentDraftError('AI could not generate a safe update. Edit the note or write the update manually.', 422);
|
||||
const completion = result.content;
|
||||
if (!completion) {
|
||||
await recordAiEvent(env, {
|
||||
kind: 'manual_draft',
|
||||
model: settings.model,
|
||||
outcome: 'failed',
|
||||
reason: result.failure,
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
contextPreview: context,
|
||||
});
|
||||
throw new IncidentDraftError('AI could not generate a safe update. Edit the note or write the update manually.', 422);
|
||||
}
|
||||
|
||||
const titleMatch = completion.match(/(?:^|\n)TITLE:\s*(.+?)(?=\nBODY:|$)/is);
|
||||
const bodyMatch = completion.match(/(?:^|\n)BODY:\s*([\s\S]+)$/i);
|
||||
const title = input.withTitle ? sanitizePublicText(titleMatch?.[1] ?? '', 120)?.replace(/[.!?]+$/, '') : null;
|
||||
const body = sanitizePublicText(bodyMatch?.[1] ?? '', 400);
|
||||
if ((input.withTitle && !title) || !body) {
|
||||
await recordAiEvent(env, {
|
||||
kind: 'manual_draft',
|
||||
model: settings.model,
|
||||
outcome: 'rejected',
|
||||
reason: 'sanitizer_rejected',
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
contextPreview: context,
|
||||
outputPreview: completion,
|
||||
});
|
||||
throw new IncidentDraftError('AI could not generate a safe update. Edit the note or write the update manually.', 422);
|
||||
}
|
||||
await recordAiEvent(env, {
|
||||
kind: 'manual_draft',
|
||||
model: settings.model,
|
||||
outcome: 'ok',
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
contextPreview: context,
|
||||
outputPreview: completion,
|
||||
});
|
||||
return { title: title ?? null, body };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { eq } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings } from '../db/schema';
|
||||
import { requestCompletion } from './client';
|
||||
import { requestCompletionDetailed } from './client';
|
||||
import { recordAiEvent } from './events';
|
||||
import { buildIncidentContext } from './incident-context';
|
||||
import { sanitizePublicText } from './sanitize';
|
||||
|
||||
@@ -32,14 +33,28 @@ export async function generateIncidentMessage(env: Env, input: { monitor: Monito
|
||||
const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||
if (!settings?.enabled || !settings.baseUrl || !settings.apiKey || !settings.model) return null;
|
||||
|
||||
const content = await requestCompletion(
|
||||
const context = await buildIncidentContext(db, input.monitor, input.result);
|
||||
const result = await requestCompletionDetailed(
|
||||
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
|
||||
INCIDENT_MESSAGE_SYSTEM_PROMPT,
|
||||
await buildIncidentContext(db, input.monitor, input.result),
|
||||
context,
|
||||
);
|
||||
if (!content) return null;
|
||||
const message = sanitizeIncidentMessage(content);
|
||||
if (!message) return null;
|
||||
const message = result.content ? sanitizeIncidentMessage(result.content) : null;
|
||||
if (!message) {
|
||||
await recordAiEvent(env, {
|
||||
kind: 'incident_open',
|
||||
monitorId: input.monitor.id,
|
||||
model: settings.model,
|
||||
outcome: result.failure ? 'failed' : 'rejected',
|
||||
reason: result.failure ?? 'sanitizer_rejected',
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
contextPreview: context,
|
||||
outputPreview: result.content,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO incident_updates (incident_id, status, body, source, created_at)
|
||||
@@ -53,6 +68,17 @@ export async function generateIncidentMessage(env: Env, input: { monitor: Monito
|
||||
)
|
||||
.bind(message, Date.now(), input.monitor.id)
|
||||
.run();
|
||||
await recordAiEvent(env, {
|
||||
kind: 'incident_open',
|
||||
monitorId: input.monitor.id,
|
||||
model: settings.model,
|
||||
outcome: 'ok',
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
contextPreview: context,
|
||||
outputPreview: result.content,
|
||||
});
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export function sanitizePublicText(value: string, maxLength: number): string | null {
|
||||
export type SanitizeReason = 'contains_url' | 'contains_ip' | 'contains_http_status' | 'empty';
|
||||
|
||||
export function sanitizePublicTextWithReason(value: string, maxLength: number): { text: string | null; reason: SanitizeReason | null } {
|
||||
const message = value
|
||||
.replace(/\r/g, '')
|
||||
.trim()
|
||||
@@ -7,9 +9,15 @@ export function sanitizePublicText(value: string, maxLength: number): string | n
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
if (!message) return null;
|
||||
if (/https?:\/\//i.test(message)) return null;
|
||||
if (/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(message)) return null;
|
||||
if (/\bHTTP[\s/]?\d{3}\b/i.test(message) || /\b[45]\d{2}\s+(?:error|status|response)\b/i.test(message)) return null;
|
||||
return message;
|
||||
if (!message) return { text: null, reason: 'empty' };
|
||||
if (/https?:\/\//i.test(message)) return { text: null, reason: 'contains_url' };
|
||||
if (/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(message)) return { text: null, reason: 'contains_ip' };
|
||||
if (/\bHTTP[\s/]?\d{3}\b/i.test(message) || /\b[45]\d{2}\s+(?:error|status|response)\b/i.test(message)) {
|
||||
return { text: null, reason: 'contains_http_status' };
|
||||
}
|
||||
return { text: message, reason: null };
|
||||
}
|
||||
|
||||
export function sanitizePublicText(value: string, maxLength: number): string | null {
|
||||
return sanitizePublicTextWithReason(value, maxLength).text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type AutopilotIncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
export type StatusSignal = {
|
||||
consecutiveFailures: number;
|
||||
failureSignatureStable: boolean;
|
||||
consecutiveOk: number;
|
||||
latestOk: boolean;
|
||||
regressionUsed: boolean;
|
||||
};
|
||||
|
||||
export type ImpactSignal = {
|
||||
kind: 'down' | 'degraded';
|
||||
affectedMonitors: number;
|
||||
totalMonitors: number;
|
||||
recentChecks: boolean[];
|
||||
};
|
||||
|
||||
export function nextFollowupDueAt(lastUpdateAt: number, autoUpdateCount: number, cadenceMinutes: number): number {
|
||||
const exponent = Math.max(0, autoUpdateCount - 1);
|
||||
const multiplier = Math.min(8, 2 ** exponent);
|
||||
return lastUpdateAt + cadenceMinutes * multiplier * 60_000;
|
||||
}
|
||||
|
||||
export function advanceStatus(current: AutopilotIncidentStatus, signal: StatusSignal): AutopilotIncidentStatus {
|
||||
if (current === 'resolved') return current;
|
||||
if (current === 'monitoring' && !signal.latestOk) return signal.regressionUsed ? current : 'identified';
|
||||
if ((current === 'investigating' || current === 'identified') && signal.consecutiveOk >= 2 && !signal.regressionUsed) return 'monitoring';
|
||||
if (current === 'investigating' && signal.consecutiveFailures >= 3 && signal.failureSignatureStable) return 'identified';
|
||||
return current;
|
||||
}
|
||||
|
||||
export function computeImpact(signal: ImpactSignal): 'minor' | 'major' | 'critical' {
|
||||
if (signal.kind === 'degraded') return 'minor';
|
||||
if (signal.affectedMonitors >= 2 && signal.totalMonitors > 0 && signal.affectedMonitors / signal.totalMonitors >= 0.5) return 'critical';
|
||||
if (signal.affectedMonitors >= 3) return 'critical';
|
||||
if (signal.recentChecks.length >= 10 && signal.recentChecks.slice(0, 10).every((ok) => !ok)) return 'major';
|
||||
if (signal.recentChecks.some(Boolean)) return 'minor';
|
||||
return 'major';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type AutopilotCandidate = {
|
||||
source: string;
|
||||
resolvedAt: Date | null;
|
||||
hasManualUpdate: boolean;
|
||||
alertsEnabled: boolean;
|
||||
};
|
||||
|
||||
export function autopilotEligible(candidate: AutopilotCandidate): boolean {
|
||||
return candidate.source === 'auto' && candidate.resolvedAt === null && !candidate.hasManualUpdate && candidate.alertsEnabled;
|
||||
}
|
||||
|
||||
export const AUTOPILOT_WRITE_GUARD = `
|
||||
i.source = 'auto'
|
||||
AND i.resolved_at IS NULL
|
||||
AND i.updated_at = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM incident_updates manual
|
||||
WHERE manual.incident_id = i.id AND manual.source = 'manual'
|
||||
)`;
|
||||
@@ -0,0 +1,379 @@
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { requestCompletionDetailed, type CompletionResult } from '../ai/client';
|
||||
import { DEGRADED_RECOVERY_UPDATE_BODY, RECOVERY_UPDATE_BODY } from '../ai/fallback-message';
|
||||
import { buildIncidentContext } from '../ai/incident-context';
|
||||
import { recordAiEvent, type AiEventKind } from '../ai/events';
|
||||
import {
|
||||
AUTOPILOT_STATUS_GUIDANCE,
|
||||
INCIDENT_FOLLOWUP_SYSTEM_PROMPT,
|
||||
INCIDENT_OPEN_SYSTEM_PROMPT,
|
||||
INCIDENT_RESOLVE_SYSTEM_PROMPT,
|
||||
} from '../ai/autopilot-prompts';
|
||||
import { sanitizePublicTextWithReason } from '../ai/sanitize';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings, incidentUpdates, incidents } from '../db/schema';
|
||||
import { humanizeDuration } from '../lib/humanize';
|
||||
import { advanceStatus, computeImpact, nextFollowupDueAt, type AutopilotIncidentStatus } from './cadence';
|
||||
import { loadIncidentSignal, findLatestAutoIncidentForMonitor } from './signal';
|
||||
|
||||
export const MAX_AI_CALLS_PER_RUN = 12;
|
||||
export const MAX_FOLLOWUP_CALLS_PER_RUN = 6;
|
||||
export const AUTOPILOT_CONCURRENCY = 4;
|
||||
export const AUTOPILOT_DEADLINE_MS = 45_000;
|
||||
|
||||
export type AiBudget = { remaining: number; deadline?: number };
|
||||
export type AutopilotEvent = {
|
||||
monitor: Monitor;
|
||||
result: CheckResult;
|
||||
transition: 'opened' | 'resolved' | null;
|
||||
latencyTransition: 'degraded' | 'recovered' | null;
|
||||
checkedAt: Date;
|
||||
};
|
||||
export type AutopilotSummary = { calls: number; written: number; rejected: number; failed: number; skipped: number };
|
||||
|
||||
type Settings = typeof aiSettings.$inferSelect;
|
||||
type Task = { kind: AiEventKind; incidentId: number; monitorId: number; run: () => Promise<boolean> };
|
||||
|
||||
function completionEvent(result: CompletionResult) {
|
||||
return {
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
outputPreview: result.content,
|
||||
};
|
||||
}
|
||||
|
||||
async function complete(settings: Settings, system: string, user: string, maxTokens = 180) {
|
||||
return requestCompletionDetailed(
|
||||
{ baseUrl: settings.baseUrl!, apiKey: settings.apiKey!, model: settings.model! },
|
||||
system,
|
||||
user,
|
||||
maxTokens,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeCasUpdate(
|
||||
env: Env,
|
||||
incident: typeof incidents.$inferSelect,
|
||||
status: string,
|
||||
body: string,
|
||||
now: number,
|
||||
): Promise<boolean> {
|
||||
const results = await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
`INSERT INTO incident_updates (incident_id, status, body, source, created_at)
|
||||
SELECT i.id, ?, ?, 'ai', ? FROM incidents i
|
||||
WHERE i.id = ? AND i.source = 'auto' AND i.resolved_at IS NULL AND i.updated_at = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM incident_updates m WHERE m.incident_id = i.id AND m.source = 'manual')`,
|
||||
).bind(status, body, now, incident.id, incident.updatedAt.getTime()),
|
||||
env.DB.prepare(
|
||||
`UPDATE incidents SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND source = 'auto' AND resolved_at IS NULL AND updated_at = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM incident_updates m WHERE m.incident_id = incidents.id AND m.source = 'manual')`,
|
||||
).bind(status, now, incident.id, incident.updatedAt.getTime()),
|
||||
]);
|
||||
return Number(results[0].meta.changes ?? 0) === 1 && Number(results[1].meta.changes ?? 0) === 1;
|
||||
}
|
||||
|
||||
async function processOpening(env: Env, settings: Settings, event: AutopilotEvent, incidentId: number, kind: 'down' | 'degraded') {
|
||||
const db = getDb(env);
|
||||
const [incident] = await db.select().from(incidents).where(eq(incidents.id, incidentId)).limit(1);
|
||||
if (!incident || incident.resolvedAt || incident.source !== 'auto') return false;
|
||||
const signal = await loadIncidentSignal(db, incident.id);
|
||||
if (!signal) return false;
|
||||
const context = await buildIncidentContext(db, event.monitor, event.result);
|
||||
const result = await complete(settings, INCIDENT_OPEN_SYSTEM_PROMPT, context, 220);
|
||||
if (!result.content) {
|
||||
await recordAiEvent(env, {
|
||||
kind: kind === 'degraded' ? 'degraded_open' : 'incident_open',
|
||||
incidentId,
|
||||
monitorId: event.monitor.id,
|
||||
model: settings.model,
|
||||
outcome: 'failed',
|
||||
reason: result.failure,
|
||||
contextPreview: context,
|
||||
...completionEvent(result),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const titleRaw = result.content.match(/(?:^|\n)TITLE:\s*(.+?)(?=\nBODY:|$)/is)?.[1] ?? '';
|
||||
const bodyRaw = result.content.match(/(?:^|\n)BODY:\s*([\s\S]+)$/i)?.[1] ?? '';
|
||||
const title = sanitizePublicTextWithReason(titleRaw, 120);
|
||||
const body = sanitizePublicTextWithReason(bodyRaw, 280);
|
||||
const impact = computeImpact({
|
||||
kind,
|
||||
affectedMonitors: signal.affectedMonitors,
|
||||
totalMonitors: signal.totalMonitors,
|
||||
recentChecks: signal.recentChecks.map((check) => check.ok),
|
||||
});
|
||||
const now = Date.now();
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
if (body.text) {
|
||||
statements.push(
|
||||
env.DB.prepare(
|
||||
`INSERT INTO incident_updates (incident_id, status, body, source, created_at)
|
||||
SELECT i.id, i.status, ?, 'ai', ? FROM incidents i
|
||||
WHERE i.id = ? AND i.source = 'auto' AND i.resolved_at IS NULL AND i.updated_at = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM incident_updates u WHERE u.incident_id = i.id)`,
|
||||
).bind(body.text, now, incident.id, incident.updatedAt.getTime()),
|
||||
);
|
||||
}
|
||||
statements.push(
|
||||
env.DB.prepare(
|
||||
`UPDATE incidents SET title = coalesce(?, title), impact = ?, updated_at = ?
|
||||
WHERE id = ? AND source = 'auto' AND resolved_at IS NULL AND updated_at = ? AND title IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM incident_updates m WHERE m.incident_id = incidents.id AND m.source = 'manual')`,
|
||||
).bind(title.text, impact, now, incident.id, incident.updatedAt.getTime()),
|
||||
);
|
||||
const writeResults = await env.DB.batch(statements);
|
||||
const wrote = writeResults.some((writeResult) => Number(writeResult.meta.changes ?? 0) > 0);
|
||||
await recordAiEvent(env, {
|
||||
kind: kind === 'degraded' ? 'degraded_open' : 'incident_open',
|
||||
incidentId,
|
||||
monitorId: event.monitor.id,
|
||||
model: settings.model,
|
||||
outcome: title.text || body.text ? (wrote ? 'ok' : 'skipped_standdown') : 'rejected',
|
||||
reason:
|
||||
[title.reason && `title:${title.reason}`, body.reason && `body:${body.reason}`].filter(Boolean).join(',') ||
|
||||
(wrote ? null : 'cas_conflict_or_manual_update'),
|
||||
contextPreview: context,
|
||||
...completionEvent(result),
|
||||
});
|
||||
return wrote;
|
||||
}
|
||||
|
||||
async function processResolution(env: Env, settings: Settings, event: AutopilotEvent, incidentId: number, fallbackBody: string) {
|
||||
const db = getDb(env);
|
||||
const [incident] = await db.select().from(incidents).where(eq(incidents.id, incidentId)).limit(1);
|
||||
if (!incident?.resolvedAt) return false;
|
||||
const context = `Service name: ${event.monitor.name}\nObserved recovery: automated checks are healthy\nIncident duration: ${humanizeDuration(incident.durationMs ?? incident.resolvedAt.getTime() - incident.startedAt.getTime())}`;
|
||||
const result = await complete(settings, INCIDENT_RESOLVE_SYSTEM_PROMPT, context);
|
||||
const sanitized = result.content ? sanitizePublicTextWithReason(result.content, 280) : { text: null, reason: null };
|
||||
let wrote = false;
|
||||
if (sanitized.text) {
|
||||
const write = await env.DB.prepare(
|
||||
`UPDATE incident_updates SET body = ?, source = 'ai'
|
||||
WHERE id = (
|
||||
SELECT iu.id FROM incident_updates iu
|
||||
WHERE iu.incident_id = ? AND iu.status = 'resolved' AND iu.source = 'system'
|
||||
AND iu.body = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM incident_updates manual
|
||||
WHERE manual.incident_id = iu.incident_id AND manual.source = 'manual'
|
||||
)
|
||||
ORDER BY iu.id DESC LIMIT 1
|
||||
)`,
|
||||
)
|
||||
.bind(sanitized.text, incidentId, fallbackBody)
|
||||
.run();
|
||||
wrote = Number(write.meta.changes ?? 0) === 1;
|
||||
}
|
||||
await recordAiEvent(env, {
|
||||
kind: 'incident_resolve',
|
||||
incidentId,
|
||||
monitorId: event.monitor.id,
|
||||
model: settings.model,
|
||||
outcome: sanitized.text ? (wrote ? 'ok' : 'skipped_standdown') : result.failure ? 'failed' : 'rejected',
|
||||
reason: result.failure ?? sanitized.reason ?? (wrote ? null : 'manual_update_or_already_rewritten'),
|
||||
contextPreview: context,
|
||||
...completionEvent(result),
|
||||
});
|
||||
return wrote;
|
||||
}
|
||||
|
||||
async function processFollowup(env: Env, settings: Settings, incident: typeof incidents.$inferSelect) {
|
||||
const db = getDb(env);
|
||||
const signal = await loadIncidentSignal(db, incident.id);
|
||||
if (!signal) return false;
|
||||
const status = settings.autopilotAdvanceStatus
|
||||
? advanceStatus(incident.status as AutopilotIncidentStatus, signal)
|
||||
: (incident.status as AutopilotIncidentStatus);
|
||||
const guidance = AUTOPILOT_STATUS_GUIDANCE[status === 'resolved' ? 'monitoring' : status];
|
||||
const context = [
|
||||
`Service name: ${signal.monitor.name}`,
|
||||
`Incident age: ${humanizeDuration(Date.now() - incident.startedAt.getTime())}`,
|
||||
`Current state: ${status}`,
|
||||
`Recent checks: ${signal.recentChecks.filter((check) => check.ok).length} healthy of ${signal.recentChecks.length}`,
|
||||
`Writing guidance: ${guidance}`,
|
||||
].join('\n');
|
||||
const result = await complete(settings, INCIDENT_FOLLOWUP_SYSTEM_PROMPT, context);
|
||||
const sanitized = result.content ? sanitizePublicTextWithReason(result.content, 280) : { text: null, reason: null };
|
||||
const wrote = sanitized.text ? await writeCasUpdate(env, incident, status, sanitized.text, Date.now()) : false;
|
||||
await recordAiEvent(env, {
|
||||
kind: 'incident_followup',
|
||||
incidentId: incident.id,
|
||||
monitorId: signal.monitor.id,
|
||||
model: settings.model,
|
||||
outcome: sanitized.text ? (wrote ? 'ok' : 'skipped_standdown') : result.failure ? 'failed' : 'rejected',
|
||||
reason: result.failure ?? sanitized.reason ?? (wrote ? null : 'cas_conflict_or_manual_update'),
|
||||
contextPreview: context,
|
||||
...completionEvent(result),
|
||||
});
|
||||
return wrote;
|
||||
}
|
||||
|
||||
async function loadFollowupTasks(env: Env, settings: Settings, excluded: Set<number>): Promise<Task[]> {
|
||||
const db = getDb(env);
|
||||
const rows = await db
|
||||
.select({
|
||||
incident: incidents,
|
||||
lastUpdateAt: sql<number>`coalesce(max(${incidentUpdates.createdAt}), ${incidents.startedAt})`,
|
||||
autoUpdateCount: sql<number>`coalesce(sum(case when ${incidentUpdates.source} in ('ai','system') then 1 else 0 end), 0)`,
|
||||
hasManual: sql<number>`coalesce(max(case when ${incidentUpdates.source} = 'manual' then 1 else 0 end), 0)`,
|
||||
})
|
||||
.from(incidents)
|
||||
.leftJoin(incidentUpdates, eq(incidentUpdates.incidentId, incidents.id))
|
||||
.where(and(eq(incidents.source, 'auto'), isNull(incidents.resolvedAt)))
|
||||
.groupBy(incidents.id)
|
||||
.orderBy(sql`coalesce(max(${incidentUpdates.createdAt}), ${incidents.startedAt}) asc`);
|
||||
const tasks: Task[] = [];
|
||||
for (const row of rows) {
|
||||
if (excluded.has(row.incident.id)) continue;
|
||||
if (Number(row.hasManual) > 0) {
|
||||
const [alreadyLogged] = await db
|
||||
.select({ id: sql<number>`id` })
|
||||
.from(sql`ai_events`)
|
||||
.where(sql`incident_id = ${row.incident.id} and kind = 'incident_followup' and outcome = 'skipped_standdown'`)
|
||||
.limit(1);
|
||||
if (alreadyLogged) continue;
|
||||
const [monitor] = await db
|
||||
.select({ id: sql<number>`monitor_id` })
|
||||
.from(sql`incident_monitors`)
|
||||
.where(sql`incident_id = ${row.incident.id}`)
|
||||
.limit(1);
|
||||
await recordAiEvent(env, {
|
||||
kind: 'incident_followup',
|
||||
incidentId: row.incident.id,
|
||||
monitorId: monitor?.id ?? null,
|
||||
model: settings.model,
|
||||
outcome: 'skipped_standdown',
|
||||
reason: 'manual_update_present',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const count = Number(row.autoUpdateCount);
|
||||
if (count >= settings.autopilotMaxUpdates) continue;
|
||||
const last = row.lastUpdateAt instanceof Date ? row.lastUpdateAt.getTime() : Number(row.lastUpdateAt);
|
||||
if (Date.now() < nextFollowupDueAt(last, count, settings.autopilotFollowupMinutes)) continue;
|
||||
const signal = await loadIncidentSignal(db, row.incident.id);
|
||||
if (!signal?.monitor.alertsEnabled) continue;
|
||||
tasks.push({
|
||||
kind: 'incident_followup',
|
||||
incidentId: row.incident.id,
|
||||
monitorId: signal.monitor.id,
|
||||
run: () => processFollowup(env, settings, row.incident),
|
||||
});
|
||||
if (tasks.length >= MAX_FOLLOWUP_CALLS_PER_RUN) break;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function runAutopilot(
|
||||
env: Env,
|
||||
input: { events?: AutopilotEvent[]; budget?: AiBudget; skipSweep?: boolean } = {},
|
||||
): Promise<AutopilotSummary> {
|
||||
const summary: AutopilotSummary = { calls: 0, written: 0, rejected: 0, failed: 0, skipped: 0 };
|
||||
const db = getDb(env);
|
||||
const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||
if (!settings?.enabled || !settings.autopilotEnabled || !settings.baseUrl || !settings.apiKey || !settings.model) return summary;
|
||||
const budget = input.budget ?? { remaining: MAX_AI_CALLS_PER_RUN, deadline: Date.now() + AUTOPILOT_DEADLINE_MS };
|
||||
budget.deadline ??= Date.now() + AUTOPILOT_DEADLINE_MS;
|
||||
const tasks: Task[] = [];
|
||||
const excluded = new Set<number>();
|
||||
for (const event of input.events ?? []) {
|
||||
if (!event.monitor.alertsEnabled) continue;
|
||||
if (event.transition === 'opened') {
|
||||
const incident = await findLatestAutoIncidentForMonitor(db, event.monitor.id, { resolved: false, kind: 'down' });
|
||||
if (incident) {
|
||||
excluded.add(incident.id);
|
||||
tasks.push({
|
||||
kind: 'incident_open',
|
||||
incidentId: incident.id,
|
||||
monitorId: event.monitor.id,
|
||||
run: () => processOpening(env, settings, event, incident.id, 'down'),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.transition === 'resolved') {
|
||||
const incident = await findLatestAutoIncidentForMonitor(db, event.monitor.id, { resolved: true, kind: 'down' });
|
||||
if (incident) {
|
||||
tasks.push({
|
||||
kind: 'incident_resolve',
|
||||
incidentId: incident.id,
|
||||
monitorId: event.monitor.id,
|
||||
run: () => processResolution(env, settings, event, incident.id, RECOVERY_UPDATE_BODY),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.latencyTransition === 'degraded' && settings.autopilotDegradedIncidents) {
|
||||
const incident = await findLatestAutoIncidentForMonitor(db, event.monitor.id, { resolved: false, kind: 'degraded' });
|
||||
if (incident) {
|
||||
excluded.add(incident.id);
|
||||
tasks.push({
|
||||
kind: 'degraded_open',
|
||||
incidentId: incident.id,
|
||||
monitorId: event.monitor.id,
|
||||
run: () => processOpening(env, settings, event, incident.id, 'degraded'),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.transition !== 'opened' && event.latencyTransition === 'recovered' && settings.autopilotDegradedIncidents) {
|
||||
const incident = await findLatestAutoIncidentForMonitor(db, event.monitor.id, { resolved: true, kind: 'degraded' });
|
||||
if (incident) {
|
||||
tasks.push({
|
||||
kind: 'incident_resolve',
|
||||
incidentId: incident.id,
|
||||
monitorId: event.monitor.id,
|
||||
run: () => processResolution(env, settings, event, incident.id, DEGRADED_RECOVERY_UPDATE_BODY),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!input.skipSweep) tasks.push(...(await loadFollowupTasks(env, settings, excluded)));
|
||||
const priority: Record<AiEventKind, number> = {
|
||||
incident_open: 0,
|
||||
incident_resolve: 1,
|
||||
degraded_open: 2,
|
||||
incident_followup: 3,
|
||||
manual_draft: 4,
|
||||
settings_test: 5,
|
||||
};
|
||||
tasks.sort((left, right) => priority[left.kind] - priority[right.kind]);
|
||||
|
||||
for (let offset = 0; offset < tasks.length; offset += AUTOPILOT_CONCURRENCY) {
|
||||
const batch = tasks.slice(offset, offset + AUTOPILOT_CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async (task) => {
|
||||
if (budget.remaining <= 0 || Date.now() >= budget.deadline!) {
|
||||
summary.skipped += 1;
|
||||
await recordAiEvent(env, {
|
||||
kind: task.kind,
|
||||
incidentId: task.incidentId,
|
||||
monitorId: task.monitorId,
|
||||
model: settings.model,
|
||||
outcome: 'skipped_budget',
|
||||
reason: budget.remaining <= 0 ? 'per_run_limit' : 'deadline',
|
||||
});
|
||||
return;
|
||||
}
|
||||
budget.remaining -= 1;
|
||||
summary.calls += 1;
|
||||
try {
|
||||
if (await task.run()) summary.written += 1;
|
||||
} catch (error) {
|
||||
summary.failed += 1;
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: 'autopilot task failed',
|
||||
kind: task.kind,
|
||||
incidentId: task.incidentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { and, asc, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import type { Database } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents, incidentUpdates, monitors } from '../db/schema';
|
||||
import { classifyFailure } from '../lib/humanize';
|
||||
|
||||
export type IncidentSignal = {
|
||||
monitor: Monitor;
|
||||
recentChecks: Array<{ ok: boolean; statusCode: number | null; latencyMs: number | null; error: string | null }>;
|
||||
consecutiveFailures: number;
|
||||
consecutiveOk: number;
|
||||
failureSignatureStable: boolean;
|
||||
latestOk: boolean;
|
||||
regressionUsed: boolean;
|
||||
affectedMonitors: number;
|
||||
totalMonitors: number;
|
||||
};
|
||||
|
||||
export async function findLatestAutoIncidentForMonitor(
|
||||
db: Database,
|
||||
monitorId: number,
|
||||
options: { resolved: boolean; kind?: 'down' | 'degraded' },
|
||||
) {
|
||||
const [incident] = await db
|
||||
.select()
|
||||
.from(incidents)
|
||||
.innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(incidentMonitors.monitorId, monitorId),
|
||||
eq(incidents.source, 'auto'),
|
||||
options.kind ? eq(incidents.kind, options.kind) : undefined,
|
||||
options.resolved ? isNotNull(incidents.resolvedAt) : isNull(incidents.resolvedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(incidents.startedAt))
|
||||
.limit(1);
|
||||
return incident?.incidents ?? null;
|
||||
}
|
||||
|
||||
export async function loadIncidentSignal(db: Database, incidentId: number): Promise<IncidentSignal | null> {
|
||||
const [monitor] = await db
|
||||
.select({ monitor: monitors })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(monitors, eq(monitors.id, incidentMonitors.monitorId))
|
||||
.where(eq(incidentMonitors.incidentId, incidentId))
|
||||
.orderBy(asc(incidentMonitors.monitorId))
|
||||
.limit(1);
|
||||
if (!monitor) return null;
|
||||
|
||||
const recentChecks = await db
|
||||
.select({ ok: checks.ok, statusCode: checks.statusCode, latencyMs: checks.latencyMs, error: checks.error })
|
||||
.from(checks)
|
||||
.where(and(eq(checks.monitorId, monitor.monitor.id), eq(checks.maintenance, false)))
|
||||
.orderBy(desc(checks.checkedAt))
|
||||
.limit(10);
|
||||
let consecutiveFailures = 0;
|
||||
let consecutiveOk = 0;
|
||||
for (const check of recentChecks) {
|
||||
if (check.ok) break;
|
||||
consecutiveFailures += 1;
|
||||
}
|
||||
for (const check of recentChecks) {
|
||||
if (!check.ok) break;
|
||||
consecutiveOk += 1;
|
||||
}
|
||||
const failureSignatures = recentChecks.slice(0, consecutiveFailures).map((check) =>
|
||||
classifyFailure(monitor.monitor, {
|
||||
ok: check.ok,
|
||||
degraded: false,
|
||||
statusCode: check.statusCode,
|
||||
latencyMs: check.latencyMs ?? 0,
|
||||
error: check.error,
|
||||
} satisfies CheckResult),
|
||||
);
|
||||
const statusHistory = await db
|
||||
.select({ status: incidentUpdates.status })
|
||||
.from(incidentUpdates)
|
||||
.where(eq(incidentUpdates.incidentId, incidentId))
|
||||
.orderBy(incidentUpdates.createdAt, incidentUpdates.id);
|
||||
const monitoringIndex = statusHistory.findIndex((update) => update.status === 'monitoring');
|
||||
const regressionUsed = monitoringIndex >= 0 && statusHistory.slice(monitoringIndex + 1).some((update) => update.status === 'identified');
|
||||
const [affected] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(incidentMonitors)
|
||||
.where(eq(incidentMonitors.incidentId, incidentId));
|
||||
const [total] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(monitors)
|
||||
.where(eq(monitors.enabled, true));
|
||||
return {
|
||||
monitor: monitor.monitor,
|
||||
recentChecks,
|
||||
consecutiveFailures,
|
||||
consecutiveOk,
|
||||
failureSignatureStable: failureSignatures.length >= 3 && new Set(failureSignatures).size === 1,
|
||||
latestOk: recentChecks[0]?.ok ?? false,
|
||||
regressionUsed,
|
||||
affectedMonitors: Number(affected?.count ?? 1),
|
||||
totalMonitors: Number(total?.count ?? 1),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents, incidentUpdates, monitors } from '../db/schema';
|
||||
import { RECOVERY_UPDATE_BODY } from '../ai/fallback-message';
|
||||
import { DEGRADED_RECOVERY_UPDATE_BODY, DEGRADED_SUPERSEDED_UPDATE_BODY, RECOVERY_UPDATE_BODY } from '../ai/fallback-message';
|
||||
import type { CheckResult, Monitor } from './run-check';
|
||||
|
||||
/** Only opened and resolved transitions are allowed to send alerts. */
|
||||
@@ -11,7 +11,14 @@ export type LatencyTransition = 'degraded' | 'recovered' | null;
|
||||
|
||||
type BatchStatement = Parameters<Database['batch']>[0][number];
|
||||
|
||||
export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date, maintenance = false) {
|
||||
export function buildResultStatements(
|
||||
db: Database,
|
||||
monitor: Monitor,
|
||||
result: CheckResult,
|
||||
checkedAt: Date,
|
||||
maintenance = false,
|
||||
options: { degradedIncidents?: boolean } = {},
|
||||
) {
|
||||
const statements: BatchStatement[] = [
|
||||
db.insert(checks).values({
|
||||
monitorId: monitor.id,
|
||||
@@ -78,11 +85,14 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
|
||||
let transition: CheckTransition = null;
|
||||
if (!wasDown && isDown) {
|
||||
const localImpact =
|
||||
result.statusCode === null || (result.statusCode !== null && result.statusCode >= 500) || nextFailures >= 10 ? 'major' : 'minor';
|
||||
statements.push(
|
||||
db.insert(incidents).values({
|
||||
status: 'investigating',
|
||||
impact: 'major',
|
||||
impact: localImpact,
|
||||
source: 'auto',
|
||||
kind: 'down',
|
||||
startedAt: checkedAt,
|
||||
startStatusCode: result.statusCode,
|
||||
startError: result.error,
|
||||
@@ -91,13 +101,60 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
}),
|
||||
db.insert(incidentMonitors).values({ incidentId: sql`last_insert_rowid()`, monitorId: monitor.id }),
|
||||
);
|
||||
// Keep this after incident_monitors: that statement must consume the new down incident rowid first.
|
||||
if (options.degradedIncidents) {
|
||||
const degradedIds = db
|
||||
.select({ id: incidentMonitors.incidentId })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(incidents, eq(incidents.id, incidentMonitors.incidentId))
|
||||
.where(
|
||||
and(
|
||||
eq(incidentMonitors.monitorId, monitor.id),
|
||||
eq(incidents.source, 'auto'),
|
||||
eq(incidents.kind, 'degraded'),
|
||||
isNull(incidents.resolvedAt),
|
||||
),
|
||||
);
|
||||
statements.push(
|
||||
db.insert(incidentUpdates).select(
|
||||
db
|
||||
.select({
|
||||
id: sql<number | null>`null`.as('id'),
|
||||
incidentId: incidents.id,
|
||||
status: sql<string>`'resolved'`.as('status'),
|
||||
body: sql<string>`${DEGRADED_SUPERSEDED_UPDATE_BODY}`.as('body'),
|
||||
note: sql<string | null>`null`.as('note'),
|
||||
source: sql<string>`'system'`.as('source'),
|
||||
createdAt: sql<Date>`${checkedAt.getTime()}`.as('created_at'),
|
||||
})
|
||||
.from(incidents)
|
||||
.where(inArray(incidents.id, degradedIds)),
|
||||
),
|
||||
db
|
||||
.update(incidents)
|
||||
.set({
|
||||
status: 'resolved',
|
||||
resolvedAt: checkedAt,
|
||||
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(inArray(incidents.id, degradedIds)),
|
||||
);
|
||||
}
|
||||
transition = 'opened';
|
||||
} else if (wasDown && result.ok) {
|
||||
const openIncidentIds = db
|
||||
.select({ id: incidentMonitors.incidentId })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(incidents, eq(incidents.id, incidentMonitors.incidentId))
|
||||
.where(and(eq(incidentMonitors.monitorId, monitor.id), eq(incidents.source, 'auto'), isNull(incidents.resolvedAt)));
|
||||
.where(
|
||||
and(
|
||||
eq(incidentMonitors.monitorId, monitor.id),
|
||||
eq(incidents.source, 'auto'),
|
||||
eq(incidents.kind, 'down'),
|
||||
isNull(incidents.resolvedAt),
|
||||
),
|
||||
);
|
||||
statements.push(
|
||||
db.insert(incidentUpdates).select(
|
||||
db
|
||||
@@ -111,7 +168,14 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
createdAt: sql<Date>`${checkedAt.getTime()}`.as('created_at'),
|
||||
})
|
||||
.from(incidents)
|
||||
.where(and(eq(incidents.source, 'auto'), isNull(incidents.resolvedAt), inArray(incidents.id, openIncidentIds))),
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.source, 'auto'),
|
||||
eq(incidents.kind, 'down'),
|
||||
isNull(incidents.resolvedAt),
|
||||
inArray(incidents.id, openIncidentIds),
|
||||
),
|
||||
),
|
||||
),
|
||||
db
|
||||
.update(incidents)
|
||||
@@ -121,11 +185,73 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(and(eq(incidents.source, 'auto'), isNull(incidents.resolvedAt), inArray(incidents.id, openIncidentIds))),
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.source, 'auto'),
|
||||
eq(incidents.kind, 'down'),
|
||||
isNull(incidents.resolvedAt),
|
||||
inArray(incidents.id, openIncidentIds),
|
||||
),
|
||||
),
|
||||
);
|
||||
transition = 'resolved';
|
||||
} else if (!wasDown && !result.ok) transition = 'pending';
|
||||
else if (!wasDown && result.ok && previousFailures > 0) transition = 'cleared';
|
||||
|
||||
if (options.degradedIncidents && monitor.alertsEnabled && latencyTransition === 'degraded' && !isDown) {
|
||||
statements.push(
|
||||
db.insert(incidents).values({
|
||||
status: 'investigating',
|
||||
impact: 'minor',
|
||||
source: 'auto',
|
||||
kind: 'degraded',
|
||||
startedAt: checkedAt,
|
||||
startStatusCode: result.statusCode,
|
||||
startError: result.error,
|
||||
createdAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
}),
|
||||
db.insert(incidentMonitors).values({ incidentId: sql`last_insert_rowid()`, monitorId: monitor.id }),
|
||||
);
|
||||
} else if (options.degradedIncidents && latencyTransition === 'recovered') {
|
||||
const degradedIds = db
|
||||
.select({ id: incidentMonitors.incidentId })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(incidents, eq(incidents.id, incidentMonitors.incidentId))
|
||||
.where(
|
||||
and(
|
||||
eq(incidentMonitors.monitorId, monitor.id),
|
||||
eq(incidents.source, 'auto'),
|
||||
eq(incidents.kind, 'degraded'),
|
||||
isNull(incidents.resolvedAt),
|
||||
),
|
||||
);
|
||||
statements.push(
|
||||
db.insert(incidentUpdates).select(
|
||||
db
|
||||
.select({
|
||||
id: sql<number | null>`null`.as('id'),
|
||||
incidentId: incidents.id,
|
||||
status: sql<string>`'resolved'`.as('status'),
|
||||
body: sql<string>`${DEGRADED_RECOVERY_UPDATE_BODY}`.as('body'),
|
||||
note: sql<string | null>`null`.as('note'),
|
||||
source: sql<string>`'system'`.as('source'),
|
||||
createdAt: sql<Date>`${checkedAt.getTime()}`.as('created_at'),
|
||||
})
|
||||
.from(incidents)
|
||||
.where(inArray(incidents.id, degradedIds)),
|
||||
),
|
||||
db
|
||||
.update(incidents)
|
||||
.set({
|
||||
status: 'resolved',
|
||||
resolvedAt: checkedAt,
|
||||
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(inArray(incidents.id, degradedIds)),
|
||||
);
|
||||
}
|
||||
|
||||
return { statements, transition, latencyTransition, consecutiveFailures: nextFailures };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { generateIncidentMessage } from '../ai/incident-message';
|
||||
import type { AutopilotEvent } from '../autopilot';
|
||||
import { getDb } from '../db/client';
|
||||
import { monitors } from '../db/schema';
|
||||
import { aiSettings, monitors } from '../db/schema';
|
||||
import { loadActiveMaintenance } from '../maintenance/windows';
|
||||
import { buildAlertEvent } from '../notifications/compose';
|
||||
import { dispatchNotification, MAX_NOTIFICATIONS_PER_RUN, type NotificationBudget } from '../notifications/dispatch';
|
||||
import { type AlertTransition, buildResultStatements } from './persist-result';
|
||||
import { buildResultStatements } from './persist-result';
|
||||
import { runCheck, runCheckWithRetries, type RetryBudget } from './run-check';
|
||||
|
||||
const MAX_MONITORS_PER_RUN = 40;
|
||||
const MAX_AI_MESSAGES_PER_RUN = 10;
|
||||
const CONCURRENCY = 10;
|
||||
const MAX_BATCH_STATEMENTS = 100;
|
||||
export const MAX_RETRY_ATTEMPTS_PER_RUN = 60;
|
||||
const RETRY_DEADLINE_MS = 90_000;
|
||||
|
||||
@@ -20,6 +21,7 @@ export type DueCheckSummary = {
|
||||
pending: number;
|
||||
opened: number;
|
||||
retries: number;
|
||||
events: AutopilotEvent[];
|
||||
};
|
||||
|
||||
export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitUntil'>): Promise<DueCheckSummary> {
|
||||
@@ -37,8 +39,11 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
||||
.orderBy(sql`${monitors.lastCheckedAt} ASC NULLS FIRST`)
|
||||
.limit(MAX_MONITORS_PER_RUN);
|
||||
|
||||
if (due.length === 0) return { checked: 0, up: 0, down: 0, pending: 0, opened: 0, retries: 0 };
|
||||
const activeMaintenance = await loadActiveMaintenance(db, new Date());
|
||||
if (due.length === 0) return { checked: 0, up: 0, down: 0, pending: 0, opened: 0, retries: 0, events: [] };
|
||||
const [activeMaintenance, [settings]] = await Promise.all([
|
||||
loadActiveMaintenance(db, new Date()),
|
||||
db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1),
|
||||
]);
|
||||
const budget: RetryBudget = { remaining: MAX_RETRY_ATTEMPTS_PER_RUN, deadline: Date.now() + RETRY_DEADLINE_MS };
|
||||
|
||||
const completed: Array<{
|
||||
@@ -65,58 +70,31 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
||||
monitor,
|
||||
result,
|
||||
checkedAt,
|
||||
...buildResultStatements(db, monitor, result, checkedAt, maintenance),
|
||||
...buildResultStatements(db, monitor, result, checkedAt, maintenance, {
|
||||
degradedIncidents: Boolean(settings?.autopilotEnabled && settings.autopilotDegradedIncidents),
|
||||
}),
|
||||
}));
|
||||
const statements = persisted.flatMap((item) => item.statements);
|
||||
let statementChunk: (typeof persisted)[number]['statements'] = [];
|
||||
for (const item of persisted) {
|
||||
if (statementChunk.length > 0 && statementChunk.length + item.statements.length > MAX_BATCH_STATEMENTS) {
|
||||
await db.batch(statementChunk as [(typeof statementChunk)[number], ...typeof statementChunk]);
|
||||
statementChunk = [];
|
||||
}
|
||||
statementChunk.push(...item.statements);
|
||||
}
|
||||
if (statementChunk.length > 0) await db.batch(statementChunk as [(typeof statementChunk)[number], ...typeof statementChunk]);
|
||||
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
|
||||
let aiMessagesQueued = 0;
|
||||
const notificationBudget: NotificationBudget = { remaining: MAX_NOTIFICATIONS_PER_RUN };
|
||||
const notifications = persisted.flatMap((item) => {
|
||||
const work: Promise<unknown>[] = [];
|
||||
if (item.transition === 'opened' || item.transition === 'resolved') {
|
||||
const kind: AlertTransition = item.transition;
|
||||
work.push(
|
||||
dispatchNotification(
|
||||
env,
|
||||
{
|
||||
monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url },
|
||||
kind: kind === 'opened' ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: kind === 'opened' ? `${item.monitor.name} is down` : `${item.monitor.name} recovered`,
|
||||
body: item.result.error,
|
||||
statusCode: item.result.statusCode,
|
||||
error: item.result.error,
|
||||
at: item.checkedAt,
|
||||
},
|
||||
notificationBudget,
|
||||
),
|
||||
);
|
||||
work.push(dispatchNotification(env, buildAlertEvent(item.monitor, item.result, item.transition, item.checkedAt), notificationBudget));
|
||||
}
|
||||
if (item.latencyTransition) {
|
||||
const degraded = item.latencyTransition === 'degraded';
|
||||
work.push(
|
||||
dispatchNotification(
|
||||
env,
|
||||
{
|
||||
monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url },
|
||||
kind: degraded ? 'degraded' : 'recovered_degraded',
|
||||
incidentId: null,
|
||||
title: degraded ? `${item.monitor.name} performance degraded` : `${item.monitor.name} performance recovered`,
|
||||
body: degraded ? `Response time was ${item.result.latencyMs} ms.` : 'Response time returned to normal.',
|
||||
statusCode: item.result.statusCode,
|
||||
error: item.result.error,
|
||||
at: item.checkedAt,
|
||||
},
|
||||
notificationBudget,
|
||||
),
|
||||
dispatchNotification(env, buildAlertEvent(item.monitor, item.result, item.latencyTransition, item.checkedAt), notificationBudget),
|
||||
);
|
||||
}
|
||||
if (item.transition === 'opened' && item.monitor.alertsEnabled && aiMessagesQueued < MAX_AI_MESSAGES_PER_RUN) {
|
||||
aiMessagesQueued += 1;
|
||||
work.push(generateIncidentMessage(env, { monitor: item.monitor, result: item.result }));
|
||||
}
|
||||
return work;
|
||||
});
|
||||
if (notifications.length > 0) {
|
||||
@@ -126,6 +104,15 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
||||
}
|
||||
|
||||
const up = completed.reduce((count, item) => count + Number(item.result.ok), 0);
|
||||
const events: AutopilotEvent[] = persisted
|
||||
.filter((item) => item.transition === 'opened' || item.transition === 'resolved' || item.latencyTransition !== null)
|
||||
.map((item) => ({
|
||||
monitor: item.monitor,
|
||||
result: item.result,
|
||||
transition: item.transition === 'opened' || item.transition === 'resolved' ? item.transition : null,
|
||||
latencyTransition: item.latencyTransition,
|
||||
checkedAt: item.checkedAt,
|
||||
}));
|
||||
return {
|
||||
checked: completed.length,
|
||||
up,
|
||||
@@ -133,5 +120,6 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
||||
pending: persisted.reduce((count, item) => count + Number(item.transition === 'pending'), 0),
|
||||
opened: persisted.reduce((count, item) => count + Number(item.transition === 'opened'), 0),
|
||||
retries: completed.reduce((count, item) => count + item.result.attempts - 1, 0),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ export const aiSettings = sqliteTable('ai_settings', {
|
||||
baseUrl: text('base_url'),
|
||||
apiKey: text('api_key'),
|
||||
model: text('model'),
|
||||
autopilotEnabled: integer('autopilot_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
autopilotFollowupMinutes: integer('autopilot_followup_minutes').notNull().default(15),
|
||||
autopilotMaxUpdates: integer('autopilot_max_updates').notNull().default(6),
|
||||
autopilotAdvanceStatus: integer('autopilot_advance_status', { mode: 'boolean' }).notNull().default(false),
|
||||
autopilotDegradedIncidents: integer('autopilot_degraded_incidents', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
@@ -134,6 +139,7 @@ export const incidents = sqliteTable(
|
||||
status: text('status').notNull().default('investigating'),
|
||||
impact: text('impact').notNull().default('major'),
|
||||
source: text('source').notNull().default('auto'),
|
||||
kind: text('kind').notNull().default('down'),
|
||||
startedAt: integer('started_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
|
||||
startStatusCode: integer('start_status_code'),
|
||||
@@ -174,6 +180,26 @@ export const incidentUpdates = sqliteTable(
|
||||
(table) => [index('incident_updates_incident_id_created_at_idx').on(table.incidentId, table.createdAt)],
|
||||
);
|
||||
|
||||
export const aiEvents = sqliteTable(
|
||||
'ai_events',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
kind: text('kind').notNull(),
|
||||
incidentId: integer('incident_id').references(() => incidents.id, { onDelete: 'set null' }),
|
||||
monitorId: integer('monitor_id').references(() => monitors.id, { onDelete: 'set null' }),
|
||||
model: text('model'),
|
||||
outcome: text('outcome').notNull(),
|
||||
reason: text('reason'),
|
||||
latencyMs: integer('latency_ms'),
|
||||
promptTokens: integer('prompt_tokens'),
|
||||
completionTokens: integer('completion_tokens'),
|
||||
contextPreview: text('context_preview'),
|
||||
outputPreview: text('output_preview'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [index('ai_events_created_at_idx').on(table.createdAt)],
|
||||
);
|
||||
|
||||
export const notificationChannels = sqliteTable(
|
||||
'notification_channels',
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { csrf } from 'hono/csrf';
|
||||
import { runAutopilot } from './autopilot';
|
||||
import { runDueChecks } from './checks/run-due-checks';
|
||||
import authRoutes from './routes/auth';
|
||||
import channelRoutes from './routes/channels';
|
||||
@@ -53,12 +54,18 @@ export default {
|
||||
|
||||
await cleanupExpiredAuthRecords(env);
|
||||
const result = await runDueChecks(env, ctx);
|
||||
ctx.waitUntil(
|
||||
runAutopilot(env, { events: result.events }).then((autopilot) => {
|
||||
console.log(JSON.stringify({ message: 'autopilot run completed', ...autopilot }));
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: 'scheduled run completed',
|
||||
cron: controller.cron,
|
||||
scheduledTime: controller.scheduledTime,
|
||||
...result,
|
||||
events: result.events.length,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
|
||||
export function humanizeInterval(seconds: number): string {
|
||||
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`;
|
||||
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`;
|
||||
return `${seconds} s`;
|
||||
}
|
||||
|
||||
export function humanizeDuration(ms: number): string {
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return 'under a minute';
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||
}
|
||||
|
||||
export function classifyFailure(monitor: Pick<Monitor, 'expectedStatus'>, result: CheckResult): string {
|
||||
if (result.statusCode === null) {
|
||||
const error = (result.error ?? '').toLowerCase();
|
||||
if (/tim(?:e|ed)\s?out|timeout|deadline|aborted/.test(error))
|
||||
return 'Request timed out - no response came back before the timeout limit';
|
||||
if (/getaddrinfo|enotfound|dns|name not resolved|could not resolve|eai_again/.test(error)) {
|
||||
return 'DNS lookup failed - the hostname could not be resolved to an address';
|
||||
}
|
||||
if (/certificate|cert(?:\s|_)|self[- ]signed|tls|ssl|handshake|err_cert/.test(error)) {
|
||||
return 'TLS/SSL failure - the certificate is invalid, expired, or untrusted';
|
||||
}
|
||||
if (/econnrefused|connection refused|refused to connect/.test(error)) {
|
||||
return 'Connection refused - nothing is accepting connections at that address';
|
||||
}
|
||||
if (/econnreset|connection reset|socket hang up|premature close/.test(error))
|
||||
return 'Connection dropped before any response was returned';
|
||||
if (/ehostunreach|enetunreach|network is unreachable|no route to host/.test(error)) {
|
||||
return 'Network unreachable - the host could not be contacted at all';
|
||||
}
|
||||
return 'No HTTP response was received from the endpoint';
|
||||
}
|
||||
|
||||
const code = result.statusCode;
|
||||
if (code >= 500) return `Server error - the endpoint answered with HTTP ${code}`;
|
||||
if (code === 429) return 'The endpoint is rate limiting - HTTP 429 Too Many Requests';
|
||||
if (code === 401 || code === 403) return `The endpoint rejected the health check as unauthorized - HTTP ${code}`;
|
||||
if (code === 404) return 'The health-check path returned HTTP 404 Not Found';
|
||||
if (code >= 400) return `Client error - the endpoint answered with HTTP ${code}`;
|
||||
if (code >= 300) return `Unexpected redirect - the endpoint answered with HTTP ${code}`;
|
||||
return `The endpoint answered with HTTP ${code}, but HTTP ${monitor.expectedStatus} was expected`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import type { AlertTransition, LatencyTransition } from '../checks/persist-result';
|
||||
import type { NotificationEvent } from './providers';
|
||||
|
||||
type MonitorIdentity = Pick<Monitor, 'id' | 'name' | 'url'>;
|
||||
|
||||
export function buildAlertEvent(
|
||||
monitor: MonitorIdentity,
|
||||
result: CheckResult,
|
||||
transition: AlertTransition | LatencyTransition,
|
||||
checkedAt: Date,
|
||||
): NotificationEvent {
|
||||
if (transition === 'opened' || transition === 'resolved') {
|
||||
const opened = transition === 'opened';
|
||||
return {
|
||||
monitor,
|
||||
kind: opened ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: opened ? `${monitor.name} is down` : `${monitor.name} recovered`,
|
||||
body: result.error,
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
at: checkedAt,
|
||||
};
|
||||
}
|
||||
const degraded = transition === 'degraded';
|
||||
return {
|
||||
monitor,
|
||||
kind: degraded ? 'degraded' : 'recovered_degraded',
|
||||
incidentId: null,
|
||||
title: degraded ? `${monitor.name} performance degraded` : `${monitor.name} performance recovered`,
|
||||
body: degraded ? `Response time was ${result.latencyMs} ms.` : 'Response time returned to normal.',
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
at: checkedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { findLatestAutoIncidentForMonitor } from '../autopilot/signal';
|
||||
import { getDb } from '../db/client';
|
||||
import { incidents, incidentMonitors, notificationChannelMonitors, notificationChannels, notificationDeliveries } from '../db/schema';
|
||||
import { incidentMonitors, notificationChannelMonitors, notificationChannels, notificationDeliveries } from '../db/schema';
|
||||
import {
|
||||
CHANNEL_TYPES,
|
||||
formatChannel,
|
||||
@@ -69,19 +70,10 @@ export async function dispatchNotification(env: Env, event: NotificationEvent, b
|
||||
const db = getDb(env);
|
||||
let effectiveEvent = event;
|
||||
if (event.incidentId === null && event.monitor && (event.kind === 'down' || event.kind === 'recovered')) {
|
||||
const [incident] = await db
|
||||
.select({ id: incidents.id })
|
||||
.from(incidents)
|
||||
.innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(incidentMonitors.monitorId, event.monitor.id),
|
||||
eq(incidents.source, 'auto'),
|
||||
event.kind === 'down' ? isNull(incidents.resolvedAt) : isNotNull(incidents.resolvedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(incidents.updatedAt))
|
||||
.limit(1);
|
||||
const incident = await findLatestAutoIncidentForMonitor(db, event.monitor.id, {
|
||||
resolved: event.kind === 'recovered',
|
||||
kind: 'down',
|
||||
});
|
||||
if (incident) effectiveEvent = { ...event, incidentId: incident.id };
|
||||
}
|
||||
let targetMonitorIds = event.monitor ? [event.monitor.id] : [];
|
||||
|
||||
@@ -267,6 +267,9 @@ incidentRoutes.patch('/:id', async (context) => {
|
||||
);
|
||||
}
|
||||
if (parsed.value.monitorIds) {
|
||||
if (parsed.value.title === undefined && parsed.value.impact === undefined) {
|
||||
statements.push(db.update(incidents).set({ updatedAt: new Date() }).where(eq(incidents.id, id)));
|
||||
}
|
||||
statements.push(db.delete(incidentMonitors).where(eq(incidentMonitors.incidentId, id)));
|
||||
statements.push(...parsed.value.monitorIds.map((monitorId) => db.insert(incidentMonitors).values({ incidentId: id, monitorId })));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { generateIncidentMessage } from '../ai/incident-message';
|
||||
import { runAutopilot } from '../autopilot';
|
||||
import { buildResultStatements } from '../checks/persist-result';
|
||||
import { MAX_RETRY_COUNT, runCheckWithRetries } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents, maintenanceWindowMonitors, monitors } from '../db/schema';
|
||||
import { aiSettings, checks, incidentMonitors, incidents, maintenanceWindowMonitors, monitors } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { loadActiveMaintenance } from '../maintenance/windows';
|
||||
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||
import { readBodyLimited } from '../lib/read-body';
|
||||
import { dispatchNotification } from '../notifications/dispatch';
|
||||
import { buildAlertEvent } from '../notifications/compose';
|
||||
|
||||
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
|
||||
|
||||
@@ -600,42 +601,35 @@ monitorRoutes.post('/:id/check', async (context) => {
|
||||
const result = await runCheckWithRetries(monitor);
|
||||
const checkedAt = new Date();
|
||||
const activeMaintenance = await loadActiveMaintenance(db, checkedAt);
|
||||
const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||
const { statements, transition, latencyTransition } = buildResultStatements(
|
||||
db,
|
||||
monitor,
|
||||
result,
|
||||
checkedAt,
|
||||
activeMaintenance.has(monitor.id),
|
||||
{ degradedIncidents: Boolean(settings?.autopilotEnabled && settings.autopilotDegradedIncidents) },
|
||||
);
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
if (transition === 'opened' || transition === 'resolved') {
|
||||
await dispatchNotification(context.env, {
|
||||
monitor: { id: monitor.id, name: monitor.name, url: monitor.url },
|
||||
kind: transition === 'opened' ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: transition === 'opened' ? `${monitor.name} is down` : `${monitor.name} recovered`,
|
||||
body: result.error,
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
at: checkedAt,
|
||||
});
|
||||
if (transition === 'opened') {
|
||||
await generateIncidentMessage(context.env, { monitor, result });
|
||||
}
|
||||
await dispatchNotification(context.env, buildAlertEvent(monitor, result, transition, checkedAt));
|
||||
}
|
||||
if (latencyTransition) {
|
||||
const degraded = latencyTransition === 'degraded';
|
||||
await dispatchNotification(context.env, {
|
||||
monitor: { id: monitor.id, name: monitor.name, url: monitor.url },
|
||||
kind: degraded ? 'degraded' : 'recovered_degraded',
|
||||
incidentId: null,
|
||||
title: degraded ? `${monitor.name} performance degraded` : `${monitor.name} performance recovered`,
|
||||
body: degraded ? `Response time was ${result.latencyMs} ms.` : 'Response time returned to normal.',
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
at: checkedAt,
|
||||
});
|
||||
await dispatchNotification(context.env, buildAlertEvent(monitor, result, latencyTransition, checkedAt));
|
||||
}
|
||||
await runAutopilot(context.env, {
|
||||
events: [
|
||||
{
|
||||
monitor,
|
||||
result,
|
||||
transition: transition === 'opened' || transition === 'resolved' ? transition : null,
|
||||
latencyTransition,
|
||||
checkedAt,
|
||||
},
|
||||
],
|
||||
budget: { remaining: 1 },
|
||||
skipSweep: true,
|
||||
});
|
||||
const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1);
|
||||
|
||||
return context.json({ result, transition, latencyTransition, monitor: updated });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { desc, eq, gte, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { requestCompletion } from '../ai/client';
|
||||
import { requestCompletionDetailed } from '../ai/client';
|
||||
import { recordAiEvent } from '../ai/events';
|
||||
import { SAMPLE_INCIDENT_CONTEXT } from '../ai/incident-context';
|
||||
import { INCIDENT_MESSAGE_SYSTEM_PROMPT, sanitizeIncidentMessage } from '../ai/incident-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings } from '../db/schema';
|
||||
import { aiEvents, aiSettings } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||
|
||||
@@ -13,14 +14,30 @@ type AiInput = {
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
apiKey?: string;
|
||||
autopilotEnabled: boolean;
|
||||
autopilotFollowupMinutes: number;
|
||||
autopilotMaxUpdates: number;
|
||||
autopilotAdvanceStatus: boolean;
|
||||
autopilotDegradedIncidents: boolean;
|
||||
};
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, minimum: number, maximum: number) {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
|
||||
}
|
||||
|
||||
function parseAiInput(value: unknown): AiInput | string {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return 'Invalid request body';
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
if (typeof body.enabled !== 'boolean') return 'enabled must be a boolean';
|
||||
if (body.autopilotEnabled !== undefined && typeof body.autopilotEnabled !== 'boolean') return 'autopilotEnabled must be a boolean';
|
||||
if (body.autopilotAdvanceStatus !== undefined && typeof body.autopilotAdvanceStatus !== 'boolean') {
|
||||
return 'autopilotAdvanceStatus must be a boolean';
|
||||
}
|
||||
if (body.autopilotDegradedIncidents !== undefined && typeof body.autopilotDegradedIncidents !== 'boolean') {
|
||||
return 'autopilotDegradedIncidents must be a boolean';
|
||||
}
|
||||
|
||||
const rawBaseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : body.baseUrl;
|
||||
if (rawBaseUrl !== null && typeof rawBaseUrl !== 'string') return 'baseUrl must be a URL or null';
|
||||
@@ -46,7 +63,17 @@ function parseAiInput(value: unknown): AiInput | string {
|
||||
apiKey = body.apiKey.trim();
|
||||
}
|
||||
|
||||
return { enabled: body.enabled, baseUrl, model, apiKey };
|
||||
return {
|
||||
enabled: body.enabled,
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey,
|
||||
autopilotEnabled: body.autopilotEnabled ?? false,
|
||||
autopilotFollowupMinutes: clampInteger(body.autopilotFollowupMinutes, 15, 5, 240),
|
||||
autopilotMaxUpdates: clampInteger(body.autopilotMaxUpdates, 6, 1, 20),
|
||||
autopilotAdvanceStatus: body.autopilotAdvanceStatus ?? false,
|
||||
autopilotDegradedIncidents: body.autopilotDegradedIncidents ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function publicAiSettings(settings: typeof aiSettings.$inferSelect | undefined) {
|
||||
@@ -55,6 +82,11 @@ function publicAiSettings(settings: typeof aiSettings.$inferSelect | undefined)
|
||||
enabled: settings?.enabled ?? false,
|
||||
baseUrl: settings?.baseUrl ?? null,
|
||||
model: settings?.model ?? null,
|
||||
autopilotEnabled: settings?.autopilotEnabled ?? false,
|
||||
autopilotFollowupMinutes: settings?.autopilotFollowupMinutes ?? 15,
|
||||
autopilotMaxUpdates: settings?.autopilotMaxUpdates ?? 6,
|
||||
autopilotAdvanceStatus: settings?.autopilotAdvanceStatus ?? false,
|
||||
autopilotDegradedIncidents: settings?.autopilotDegradedIncidents ?? false,
|
||||
apiKeySet: Boolean(settings?.apiKey),
|
||||
apiKeyPreview: settings?.apiKey ? `••••••${settings.apiKey.slice(-4)}` : null,
|
||||
createdAt: settings?.createdAt ?? null,
|
||||
@@ -88,12 +120,28 @@ settingsRoutes.put('/ai', async (context) => {
|
||||
if (input.enabled && !apiKey) return context.json({ message: 'An API key is required when AI messages are enabled' }, 400);
|
||||
|
||||
const now = new Date();
|
||||
const autopilot = {
|
||||
autopilotEnabled: input.autopilotEnabled,
|
||||
autopilotFollowupMinutes: input.autopilotFollowupMinutes,
|
||||
autopilotMaxUpdates: input.autopilotMaxUpdates,
|
||||
autopilotAdvanceStatus: input.autopilotAdvanceStatus,
|
||||
autopilotDegradedIncidents: input.autopilotDegradedIncidents,
|
||||
};
|
||||
const [settings] = await db
|
||||
.insert(aiSettings)
|
||||
.values({ id: 1, enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, createdAt: now, updatedAt: now })
|
||||
.values({
|
||||
id: 1,
|
||||
enabled: input.enabled,
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey,
|
||||
model: input.model,
|
||||
...autopilot,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: aiSettings.id,
|
||||
set: { enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, updatedAt: now },
|
||||
set: { enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, ...autopilot, updatedAt: now },
|
||||
})
|
||||
.returning();
|
||||
return context.json({ settings: publicAiSettings(settings) });
|
||||
@@ -104,14 +152,53 @@ settingsRoutes.post('/ai/test', async (context) => {
|
||||
if (!settings?.baseUrl || !settings.apiKey || !settings.model) {
|
||||
return context.json({ message: 'Save a base URL, API key, and model first' }, 400);
|
||||
}
|
||||
const content = await requestCompletion(
|
||||
const result = await requestCompletionDetailed(
|
||||
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
|
||||
INCIDENT_MESSAGE_SYSTEM_PROMPT,
|
||||
SAMPLE_INCIDENT_CONTEXT,
|
||||
);
|
||||
const message = content ? sanitizeIncidentMessage(content) : null;
|
||||
const message = result.content ? sanitizeIncidentMessage(result.content) : null;
|
||||
await recordAiEvent(context.env, {
|
||||
kind: 'settings_test',
|
||||
model: settings.model,
|
||||
outcome: message ? 'ok' : result.failure ? 'failed' : 'rejected',
|
||||
reason: result.failure ?? (message ? null : 'sanitizer_rejected'),
|
||||
latencyMs: result.latencyMs,
|
||||
promptTokens: result.promptTokens,
|
||||
completionTokens: result.completionTokens,
|
||||
contextPreview: SAMPLE_INCIDENT_CONTEXT,
|
||||
outputPreview: result.content,
|
||||
});
|
||||
if (!message) return context.json({ message: 'AI message generation failed' }, 502);
|
||||
return context.json({ ok: true, message });
|
||||
});
|
||||
|
||||
settingsRoutes.get('/ai/events', async (context) => {
|
||||
const rawLimit = Number(context.req.query('limit') ?? 50);
|
||||
const limit = Number.isSafeInteger(rawLimit) ? Math.min(100, Math.max(1, rawLimit)) : 50;
|
||||
const db = getDb(context.env);
|
||||
const events = await db.select().from(aiEvents).orderBy(desc(aiEvents.createdAt)).limit(limit);
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const [summary] = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)`,
|
||||
ok: sql<number>`coalesce(sum(case when ${aiEvents.outcome} = 'ok' then 1 else 0 end), 0)`,
|
||||
averageLatencyMs: sql<number | null>`round(avg(${aiEvents.latencyMs}))`,
|
||||
promptTokens: sql<number>`coalesce(sum(${aiEvents.promptTokens}), 0)`,
|
||||
completionTokens: sql<number>`coalesce(sum(${aiEvents.completionTokens}), 0)`,
|
||||
})
|
||||
.from(aiEvents)
|
||||
.where(gte(aiEvents.createdAt, since));
|
||||
return context.json({
|
||||
events: events.map((event) => ({ ...event, createdAt: event.createdAt.toISOString() })),
|
||||
summary: {
|
||||
total: Number(summary?.total ?? 0),
|
||||
ok: Number(summary?.ok ?? 0),
|
||||
averageLatencyMs: summary?.averageLatencyMs === null ? null : Number(summary?.averageLatencyMs ?? 0),
|
||||
promptTokens: Number(summary?.promptTokens ?? 0),
|
||||
completionTokens: Number(summary?.completionTokens ?? 0),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
|
||||
@@ -23,6 +23,7 @@ type PublicIncident = {
|
||||
status: string;
|
||||
impact: string;
|
||||
source: string;
|
||||
kind: string;
|
||||
startedAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
durationMs: number | null;
|
||||
@@ -103,6 +104,7 @@ const incidentSelection = {
|
||||
status: incidents.status,
|
||||
impact: incidents.impact,
|
||||
source: incidents.source,
|
||||
kind: incidents.kind,
|
||||
startedAt: incidents.startedAt,
|
||||
resolvedAt: incidents.resolvedAt,
|
||||
durationMs: incidents.durationMs,
|
||||
@@ -177,10 +179,11 @@ statusRoutes.get('/', async (context) => {
|
||||
if (buckets) buckets.push(row);
|
||||
else bucketsByMonitor.set(row.monitorId, [row]);
|
||||
}
|
||||
const activeIncidentByMonitor = new Map<number, PublicIncident>();
|
||||
const downIncidentByMonitor = new Map<number, PublicIncident>();
|
||||
const degradedIncidentByMonitor = new Map<number, PublicIncident>();
|
||||
for (const incident of activeIncidentRows) {
|
||||
for (const service of incidentServices.get(incident.id) ?? [])
|
||||
if (!activeIncidentByMonitor.has(service.id)) activeIncidentByMonitor.set(service.id, incident);
|
||||
const target = incident.kind === 'degraded' ? degradedIncidentByMonitor : downIncidentByMonitor;
|
||||
for (const service of incidentServices.get(incident.id) ?? []) if (!target.has(service.id)) target.set(service.id, incident);
|
||||
}
|
||||
const services = monitorRows.map((monitor) => {
|
||||
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
||||
@@ -194,7 +197,8 @@ statusRoutes.get('/', async (context) => {
|
||||
uptimePct: roundUptime(bucket.upChecks, bucket.totalChecks),
|
||||
};
|
||||
});
|
||||
const incident = activeIncidentByMonitor.get(monitor.id);
|
||||
const downIncident = downIncidentByMonitor.get(monitor.id);
|
||||
const degradedIncident = degradedIncidentByMonitor.get(monitor.id);
|
||||
const maintenance = activeMaintenance.get(monitor.id);
|
||||
return {
|
||||
id: monitor.id,
|
||||
@@ -202,11 +206,13 @@ statusRoutes.get('/', async (context) => {
|
||||
status: maintenance ? ('maintenance' as const) : serviceStatus(monitor.lastOk, monitor.lastDegraded),
|
||||
message:
|
||||
!maintenance && monitor.lastOk === false
|
||||
? incident
|
||||
? (latestUpdates.get(incident.id)?.body ?? deterministicIncidentMessage(incident.startStatusCode))
|
||||
? downIncident
|
||||
? (latestUpdates.get(downIncident.id)?.body ?? deterministicIncidentMessage(downIncident.startStatusCode))
|
||||
: deterministicIncidentMessage(monitor.lastStatusCode)
|
||||
: !maintenance && monitor.lastOk === true && monitor.lastDegraded
|
||||
? DEGRADED_MESSAGE
|
||||
? degradedIncident
|
||||
? (latestUpdates.get(degradedIncident.id)?.body ?? DEGRADED_MESSAGE)
|
||||
: DEGRADED_MESSAGE
|
||||
: null,
|
||||
maintenance: maintenance ? { name: maintenance.name, endsAt: maintenance.endsAt.toISOString() } : null,
|
||||
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { lt } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, loginAttempts, monitorDailyStats, notificationDeliveries, sessions } from '../db/schema';
|
||||
import { aiEvents, checks, loginAttempts, monitorDailyStats, notificationDeliveries, sessions } from '../db/schema';
|
||||
|
||||
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
|
||||
const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const DAILY_STATS_RETENTION_MS = 400 * 24 * 60 * 60 * 1000;
|
||||
const DELIVERY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const AI_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
const db = getDb(env);
|
||||
@@ -17,5 +18,6 @@ export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))),
|
||||
db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))),
|
||||
db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, new Date(now.getTime() - DELIVERY_RETENTION_MS))),
|
||||
db.delete(aiEvents).where(lt(aiEvents.createdAt, new Date(now.getTime() - AI_EVENT_RETENTION_MS))),
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user