@stackbilt/aegis-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +96 -0
- package/schema.sql +586 -0
- package/src/adapters/voice/cloudflare-agent.ts +34 -0
- package/src/auth.ts +124 -0
- package/src/bluesky.ts +464 -0
- package/src/claude-tools/content.ts +188 -0
- package/src/claude-tools/email.ts +69 -0
- package/src/claude-tools/github.ts +440 -0
- package/src/claude-tools/goals.ts +116 -0
- package/src/claude-tools/index.ts +353 -0
- package/src/claude-tools/web.ts +59 -0
- package/src/claude.ts +406 -0
- package/src/codebeast.ts +200 -0
- package/src/composite.ts +715 -0
- package/src/content/column.ts +80 -0
- package/src/content/hero-image.ts +47 -0
- package/src/content/index.ts +27 -0
- package/src/content/journal.ts +91 -0
- package/src/content/roundtable.ts +163 -0
- package/src/core.ts +309 -0
- package/src/dashboard.ts +620 -0
- package/src/decision-docs.ts +284 -0
- package/src/dispatch.ts +13 -0
- package/src/edge-env.ts +58 -0
- package/src/email.ts +850 -0
- package/src/exports.ts +156 -0
- package/src/github-projects.ts +312 -0
- package/src/github.ts +670 -0
- package/src/groq.ts +247 -0
- package/src/health-page.ts +578 -0
- package/src/index.ts +89 -0
- package/src/kernel/argus-actions.ts +397 -0
- package/src/kernel/argus-correlation.ts +639 -0
- package/src/kernel/board.ts +91 -0
- package/src/kernel/briefing.ts +177 -0
- package/src/kernel/classify-memory-topic.ts +166 -0
- package/src/kernel/cognition.ts +377 -0
- package/src/kernel/court-cards.ts +163 -0
- package/src/kernel/dispatch.ts +587 -0
- package/src/kernel/domain.ts +50 -0
- package/src/kernel/dynamic-tools.ts +322 -0
- package/src/kernel/executor-port.ts +45 -0
- package/src/kernel/executors/claude.ts +73 -0
- package/src/kernel/executors/direct.ts +237 -0
- package/src/kernel/executors/groq.ts +18 -0
- package/src/kernel/executors/index.ts +87 -0
- package/src/kernel/executors/tarotscript.ts +104 -0
- package/src/kernel/executors/workers-ai.ts +54 -0
- package/src/kernel/insight-cache.ts +76 -0
- package/src/kernel/memory/agenda.ts +200 -0
- package/src/kernel/memory/blocks.ts +188 -0
- package/src/kernel/memory/consolidation.ts +194 -0
- package/src/kernel/memory/episodic.ts +241 -0
- package/src/kernel/memory/goals.ts +156 -0
- package/src/kernel/memory/graph.ts +290 -0
- package/src/kernel/memory/index.ts +11 -0
- package/src/kernel/memory/insights.ts +316 -0
- package/src/kernel/memory/procedural.ts +467 -0
- package/src/kernel/memory/pruning.ts +67 -0
- package/src/kernel/memory/recall.ts +367 -0
- package/src/kernel/memory/semantic.ts +315 -0
- package/src/kernel/memory/synthesis.ts +161 -0
- package/src/kernel/memory-adapter.ts +369 -0
- package/src/kernel/memory-guardrails.ts +76 -0
- package/src/kernel/port.ts +23 -0
- package/src/kernel/resilience.ts +322 -0
- package/src/kernel/router.ts +471 -0
- package/src/kernel/scheduled/agent-dispatch.ts +252 -0
- package/src/kernel/scheduled/argus-analytics.ts +247 -0
- package/src/kernel/scheduled/argus-heartbeat.ts +320 -0
- package/src/kernel/scheduled/argus-notify.ts +348 -0
- package/src/kernel/scheduled/board-sync.ts +110 -0
- package/src/kernel/scheduled/ci-watcher.ts +125 -0
- package/src/kernel/scheduled/cognitive-metrics.ts +377 -0
- package/src/kernel/scheduled/consolidation.ts +229 -0
- package/src/kernel/scheduled/content-drip.ts +47 -0
- package/src/kernel/scheduled/content.ts +6 -0
- package/src/kernel/scheduled/conversation-facts.ts +204 -0
- package/src/kernel/scheduled/cost-report.ts +84 -0
- package/src/kernel/scheduled/curiosity.ts +219 -0
- package/src/kernel/scheduled/dev-activity.ts +44 -0
- package/src/kernel/scheduled/digest.ts +317 -0
- package/src/kernel/scheduled/dreaming/agenda-triage.ts +115 -0
- package/src/kernel/scheduled/dreaming/facts.ts +239 -0
- package/src/kernel/scheduled/dreaming/index.ts +8 -0
- package/src/kernel/scheduled/dreaming/llm.ts +33 -0
- package/src/kernel/scheduled/dreaming/pattern-synthesis.ts +124 -0
- package/src/kernel/scheduled/dreaming/persona.ts +75 -0
- package/src/kernel/scheduled/dreaming/symbolic.ts +31 -0
- package/src/kernel/scheduled/dreaming/task-proposals.ts +80 -0
- package/src/kernel/scheduled/dreaming.ts +66 -0
- package/src/kernel/scheduled/entropy.ts +149 -0
- package/src/kernel/scheduled/escalation.ts +192 -0
- package/src/kernel/scheduled/feed-watcher.ts +206 -0
- package/src/kernel/scheduled/goals.ts +214 -0
- package/src/kernel/scheduled/governance.ts +41 -0
- package/src/kernel/scheduled/heartbeat.ts +220 -0
- package/src/kernel/scheduled/inbox-processor.ts +174 -0
- package/src/kernel/scheduled/index.ts +245 -0
- package/src/kernel/scheduled/issue-proposer.ts +478 -0
- package/src/kernel/scheduled/issue-watcher.ts +128 -0
- package/src/kernel/scheduled/pr-automerge.ts +213 -0
- package/src/kernel/scheduled/product-health.ts +107 -0
- package/src/kernel/scheduled/reflection.ts +373 -0
- package/src/kernel/scheduled/self-improvement.ts +114 -0
- package/src/kernel/scheduled/social-engage.ts +175 -0
- package/src/kernel/scheduled/task-audit.ts +60 -0
- package/src/kernel/symbolic.ts +156 -0
- package/src/kernel/types.ts +145 -0
- package/src/landing.ts +1190 -0
- package/src/lib/audit-chain/chain.ts +28 -0
- package/src/lib/audit-chain/types.ts +12 -0
- package/src/lib/observability/errors.ts +55 -0
- package/src/markdown.ts +164 -0
- package/src/mcp/handlers.ts +647 -0
- package/src/mcp/server.ts +184 -0
- package/src/mcp/tools.ts +316 -0
- package/src/mcp-client.ts +275 -0
- package/src/mcp-server.ts +2 -0
- package/src/operator/config.example.ts +60 -0
- package/src/operator/config.ts +60 -0
- package/src/operator/index.ts +46 -0
- package/src/operator/persona.example.ts +34 -0
- package/src/operator/persona.ts +34 -0
- package/src/operator/prompt-builder.ts +190 -0
- package/src/operator/types.ts +43 -0
- package/src/pulse.ts +1179 -0
- package/src/routes/bluesky.ts +116 -0
- package/src/routes/cc-tasks.ts +328 -0
- package/src/routes/codebeast.ts +1 -0
- package/src/routes/content.ts +194 -0
- package/src/routes/conversations.ts +25 -0
- package/src/routes/dynamic-tools.ts +111 -0
- package/src/routes/feedback.ts +192 -0
- package/src/routes/health.ts +147 -0
- package/src/routes/messages.ts +228 -0
- package/src/routes/observability.ts +82 -0
- package/src/routes/operator-logs.ts +42 -0
- package/src/routes/pages.ts +96 -0
- package/src/routes/sessions.ts +54 -0
- package/src/sanitize.ts +73 -0
- package/src/schema-enums.ts +155 -0
- package/src/search.ts +112 -0
- package/src/task-intelligence.ts +497 -0
- package/src/types.ts +194 -0
- package/src/ui.ts +5 -0
- package/src/version.ts +3 -0
- package/src/workers-ai-chat.ts +333 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// ARGUS Phase 3: Ambient event heartbeat
|
|
2
|
+
// Sweeps the events table for temporal patterns that point-in-time checks miss.
|
|
3
|
+
// Runs every 3 hours. No LLM calls — pure D1 queries and threshold logic.
|
|
4
|
+
//
|
|
5
|
+
// Patterns detected:
|
|
6
|
+
// 1. CI failure cluster — N+ failures in a rolling window
|
|
7
|
+
// 2. Payment anomaly — charge failures without recent successes
|
|
8
|
+
// 3. Event drought — expected source goes silent for too long
|
|
9
|
+
// 4. Velocity spike — unusual burst of events from a single source
|
|
10
|
+
//
|
|
11
|
+
// Findings route through the same notification infrastructure as Phase 2:
|
|
12
|
+
// critical → immediate email, high/medium → digest_sections.
|
|
13
|
+
|
|
14
|
+
import { type EdgeEnv } from '../dispatch.js';
|
|
15
|
+
import { resolveEmailProfile } from '../../email.js';
|
|
16
|
+
import { operatorConfig } from '../../operator/index.js';
|
|
17
|
+
|
|
18
|
+
// TODO: Wire correlation analysis into heartbeat pattern detection
|
|
19
|
+
import type { CorrelationResult, IncidentCluster, ArgusDiagnosis } from '../argus-correlation.js';
|
|
20
|
+
|
|
21
|
+
// ─── Configuration ───────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const RUN_CADENCE_HOURS = 3;
|
|
24
|
+
const COOLDOWN_MS = 12 * 60 * 60 * 1000; // 12h cooldown per pattern alert
|
|
25
|
+
|
|
26
|
+
// Pattern thresholds
|
|
27
|
+
const CI_FAILURE_WINDOW_HOURS = 6;
|
|
28
|
+
const CI_FAILURE_THRESHOLD = 3;
|
|
29
|
+
|
|
30
|
+
const PAYMENT_FAILURE_WINDOW_HOURS = 24;
|
|
31
|
+
|
|
32
|
+
const EVENT_DROUGHT_HOURS: Record<string, number> = {
|
|
33
|
+
github: 72, // no github events in 3 days = unusual
|
|
34
|
+
stripe: 168, // no stripe events in 7 days = check billing
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const VELOCITY_WINDOW_HOURS = 1;
|
|
38
|
+
const VELOCITY_THRESHOLD = 50; // 50+ events in 1 hour from single source
|
|
39
|
+
|
|
40
|
+
// ─── Pattern Detectors ──────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface PatternFinding {
|
|
43
|
+
pattern: string; // unique key for cooldown dedup
|
|
44
|
+
severity: 'critical' | 'high' | 'medium';
|
|
45
|
+
summary: string;
|
|
46
|
+
detail: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function detectCiFailureCluster(db: D1Database): Promise<PatternFinding | null> {
|
|
50
|
+
const result = await db.prepare(`
|
|
51
|
+
SELECT COUNT(*) as cnt FROM events
|
|
52
|
+
WHERE source = 'github'
|
|
53
|
+
AND event_type = 'check_run.completed'
|
|
54
|
+
AND ts > datetime('now', '-${CI_FAILURE_WINDOW_HOURS} hours')
|
|
55
|
+
AND json_extract(payload, '$.check_run.conclusion') = 'failure'
|
|
56
|
+
`).first<{ cnt: number }>();
|
|
57
|
+
|
|
58
|
+
const count = result?.cnt ?? 0;
|
|
59
|
+
if (count < CI_FAILURE_THRESHOLD) return null;
|
|
60
|
+
|
|
61
|
+
// Get the repos involved
|
|
62
|
+
const repos = await db.prepare(`
|
|
63
|
+
SELECT DISTINCT json_extract(payload, '$.repository.full_name') as repo FROM events
|
|
64
|
+
WHERE source = 'github'
|
|
65
|
+
AND event_type = 'check_run.completed'
|
|
66
|
+
AND ts > datetime('now', '-${CI_FAILURE_WINDOW_HOURS} hours')
|
|
67
|
+
AND json_extract(payload, '$.check_run.conclusion') = 'failure'
|
|
68
|
+
LIMIT 5
|
|
69
|
+
`).all<{ repo: string }>();
|
|
70
|
+
|
|
71
|
+
const repoList = repos.results.map(r => r.repo).filter(Boolean).join(', ');
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
pattern: 'ci_failure_cluster',
|
|
75
|
+
severity: count >= 5 ? 'critical' : 'high',
|
|
76
|
+
summary: `${count} CI failures in ${CI_FAILURE_WINDOW_HOURS}h`,
|
|
77
|
+
detail: `${count} check_run failures detected across: ${repoList || 'unknown repos'}. Possible systemic issue — review CI configurations.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function detectPaymentAnomaly(db: D1Database): Promise<PatternFinding | null> {
|
|
82
|
+
const failures = await db.prepare(`
|
|
83
|
+
SELECT COUNT(*) as cnt FROM events
|
|
84
|
+
WHERE source = 'stripe'
|
|
85
|
+
AND event_type IN ('charge.failed', 'invoice.payment_failed', 'payment_intent.payment_failed')
|
|
86
|
+
AND ts > datetime('now', '-${PAYMENT_FAILURE_WINDOW_HOURS} hours')
|
|
87
|
+
`).first<{ cnt: number }>();
|
|
88
|
+
|
|
89
|
+
if (!failures?.cnt || failures.cnt === 0) return null;
|
|
90
|
+
|
|
91
|
+
// Check if there were any successes in the same window
|
|
92
|
+
const successes = await db.prepare(`
|
|
93
|
+
SELECT COUNT(*) as cnt FROM events
|
|
94
|
+
WHERE source = 'stripe'
|
|
95
|
+
AND event_type IN ('charge.succeeded', 'invoice.paid', 'payment_intent.succeeded', 'checkout.session.completed')
|
|
96
|
+
AND ts > datetime('now', '-${PAYMENT_FAILURE_WINDOW_HOURS} hours')
|
|
97
|
+
`).first<{ cnt: number }>();
|
|
98
|
+
|
|
99
|
+
// Failures without any successes = likely payment system issue
|
|
100
|
+
if ((successes?.cnt ?? 0) === 0 && failures.cnt >= 2) {
|
|
101
|
+
return {
|
|
102
|
+
pattern: 'payment_anomaly',
|
|
103
|
+
severity: 'critical',
|
|
104
|
+
summary: `${failures.cnt} payment failures, 0 successes in ${PAYMENT_FAILURE_WINDOW_HOURS}h`,
|
|
105
|
+
detail: `All payment attempts are failing with no successful charges. Check Stripe dashboard for account issues, API key validity, or upstream payment processor problems.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// High failure ratio
|
|
110
|
+
const total = failures.cnt + (successes?.cnt ?? 0);
|
|
111
|
+
const failRate = failures.cnt / total;
|
|
112
|
+
if (failRate > 0.5 && failures.cnt >= 3) {
|
|
113
|
+
return {
|
|
114
|
+
pattern: 'payment_high_failure_rate',
|
|
115
|
+
severity: 'high',
|
|
116
|
+
summary: `${Math.round(failRate * 100)}% payment failure rate (${failures.cnt}/${total})`,
|
|
117
|
+
detail: `Payment failure rate above 50% in the last ${PAYMENT_FAILURE_WINDOW_HOURS}h. Not a total outage but warrants investigation.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function detectEventDrought(db: D1Database): Promise<PatternFinding[]> {
|
|
125
|
+
const findings: PatternFinding[] = [];
|
|
126
|
+
|
|
127
|
+
for (const [source, thresholdHours] of Object.entries(EVENT_DROUGHT_HOURS)) {
|
|
128
|
+
// Only check drought if we've ever received events from this source
|
|
129
|
+
const hasHistory = await db.prepare(
|
|
130
|
+
"SELECT id FROM events WHERE source = ? LIMIT 1"
|
|
131
|
+
).bind(source).first();
|
|
132
|
+
|
|
133
|
+
if (!hasHistory) continue;
|
|
134
|
+
|
|
135
|
+
const recent = await db.prepare(`
|
|
136
|
+
SELECT COUNT(*) as cnt FROM events
|
|
137
|
+
WHERE source = ? AND ts > datetime('now', '-${thresholdHours} hours')
|
|
138
|
+
`).bind(source).first<{ cnt: number }>();
|
|
139
|
+
|
|
140
|
+
if (recent?.cnt === 0) {
|
|
141
|
+
findings.push({
|
|
142
|
+
pattern: `drought_${source}`,
|
|
143
|
+
severity: 'medium',
|
|
144
|
+
summary: `No ${source} events in ${thresholdHours}h`,
|
|
145
|
+
detail: `Expected ${source} webhook events but received none in ${thresholdHours} hours. Check if the webhook is still configured and delivering.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function detectVelocitySpike(db: D1Database): Promise<PatternFinding | null> {
|
|
154
|
+
const result = await db.prepare(`
|
|
155
|
+
SELECT source, COUNT(*) as cnt FROM events
|
|
156
|
+
WHERE ts > datetime('now', '-${VELOCITY_WINDOW_HOURS} hours')
|
|
157
|
+
GROUP BY source
|
|
158
|
+
HAVING cnt > ${VELOCITY_THRESHOLD}
|
|
159
|
+
ORDER BY cnt DESC
|
|
160
|
+
LIMIT 1
|
|
161
|
+
`).first<{ source: string; cnt: number }>();
|
|
162
|
+
|
|
163
|
+
if (!result) return null;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
pattern: `velocity_spike_${result.source}`,
|
|
167
|
+
severity: 'high',
|
|
168
|
+
summary: `${result.cnt} ${result.source} events in ${VELOCITY_WINDOW_HOURS}h`,
|
|
169
|
+
detail: `Unusual event velocity from ${result.source} — ${result.cnt} events in the last hour. This could indicate a deploy storm, automated bot activity, or webhook replay.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Cooldown ────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function isOnCooldown(db: D1Database, pattern: string): Promise<boolean> {
|
|
176
|
+
const key = `argus_pattern_${pattern}`;
|
|
177
|
+
const last = await db.prepare(
|
|
178
|
+
"SELECT received_at FROM web_events WHERE event_id = ?"
|
|
179
|
+
).bind(key).first<{ received_at: string }>();
|
|
180
|
+
|
|
181
|
+
if (!last) return false;
|
|
182
|
+
return (Date.now() - new Date(last.received_at + 'Z').getTime()) < COOLDOWN_MS;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function recordCooldown(db: D1Database, pattern: string): Promise<void> {
|
|
186
|
+
const key = `argus_pattern_${pattern}`;
|
|
187
|
+
await db.prepare(
|
|
188
|
+
"INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES (?, datetime('now'))"
|
|
189
|
+
).bind(key).run();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Notification ────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async function sendPatternAlert(env: EdgeEnv, findings: PatternFinding[]): Promise<void> {
|
|
195
|
+
if (!env.resendApiKey || !env.notifyEmail) return;
|
|
196
|
+
|
|
197
|
+
const sender = resolveEmailProfile(operatorConfig.integrations.email.defaultProfile, {
|
|
198
|
+
resendApiKey: env.resendApiKey,
|
|
199
|
+
resendApiKeyPersonal: env.resendApiKeyPersonal,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const rows = findings.map(f => `
|
|
203
|
+
<tr>
|
|
204
|
+
<td style="padding:8px 12px;font-size:11px;color:${f.severity === 'critical' ? '#ef4444' : '#f5a623'};border-bottom:1px solid #1a1a2e;text-transform:uppercase">${f.severity}</td>
|
|
205
|
+
<td style="padding:8px 12px;font-size:12px;color:#e0e0e0;border-bottom:1px solid #1a1a2e;font-weight:500">${escapeHtml(f.summary)}</td>
|
|
206
|
+
</tr>
|
|
207
|
+
<tr>
|
|
208
|
+
<td colspan="2" style="padding:4px 12px 12px;font-size:11px;color:#888;border-bottom:1px solid #222">${escapeHtml(f.detail)}</td>
|
|
209
|
+
</tr>`).join('');
|
|
210
|
+
|
|
211
|
+
const html = `<!DOCTYPE html>
|
|
212
|
+
<html><head><meta charset="utf-8"></head>
|
|
213
|
+
<body style="background:#0a0a0f;color:#e0e0e0;font-family:system-ui,sans-serif;margin:0;padding:24px">
|
|
214
|
+
<div style="max-width:640px;margin:0 auto">
|
|
215
|
+
<div style="background:#1a1a2e;border:1px solid #333;border-left:4px solid #f5a623;border-radius:4px;padding:16px 20px;margin-bottom:20px">
|
|
216
|
+
<p style="margin:0 0 2px;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px">ARGUS Pattern Detection</p>
|
|
217
|
+
<p style="margin:0;font-size:16px;font-weight:600;color:#f5a623">${findings.length} Pattern${findings.length > 1 ? 's' : ''} Detected</p>
|
|
218
|
+
</div>
|
|
219
|
+
<table style="width:100%;border-collapse:collapse;background:#111;border:1px solid #222;border-radius:6px">
|
|
220
|
+
<tbody>${rows}</tbody>
|
|
221
|
+
</table>
|
|
222
|
+
<p style="margin:16px 0 0;font-size:11px;color:#444">aegis · argus heartbeat · ${new Date().toISOString()}</p>
|
|
223
|
+
</div></body></html>`;
|
|
224
|
+
|
|
225
|
+
await fetch('https://api.resend.com/emails', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sender.apiKey}` },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
from: sender.from,
|
|
230
|
+
to: [env.notifyEmail],
|
|
231
|
+
subject: `[ARGUS] ${findings[0].summary}`,
|
|
232
|
+
html,
|
|
233
|
+
}),
|
|
234
|
+
signal: AbortSignal.timeout(10_000),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Main ────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export async function runArgusHeartbeat(env: EdgeEnv): Promise<void> {
|
|
241
|
+
// Cadence gate: every 3 hours
|
|
242
|
+
const hour = new Date().getUTCHours();
|
|
243
|
+
if (hour % RUN_CADENCE_HOURS !== 0) return;
|
|
244
|
+
|
|
245
|
+
// Check if events table has any data yet — skip if empty (no webhooks configured)
|
|
246
|
+
const hasEvents = await env.db.prepare("SELECT id FROM events LIMIT 1").first();
|
|
247
|
+
if (!hasEvents) return;
|
|
248
|
+
|
|
249
|
+
// Run all pattern detectors
|
|
250
|
+
const findings: PatternFinding[] = [];
|
|
251
|
+
|
|
252
|
+
const ciCluster = await detectCiFailureCluster(env.db);
|
|
253
|
+
if (ciCluster) findings.push(ciCluster);
|
|
254
|
+
|
|
255
|
+
const paymentAnomaly = await detectPaymentAnomaly(env.db);
|
|
256
|
+
if (paymentAnomaly) findings.push(paymentAnomaly);
|
|
257
|
+
|
|
258
|
+
const droughts = await detectEventDrought(env.db);
|
|
259
|
+
findings.push(...droughts);
|
|
260
|
+
|
|
261
|
+
const velocitySpike = await detectVelocitySpike(env.db);
|
|
262
|
+
if (velocitySpike) findings.push(velocitySpike);
|
|
263
|
+
|
|
264
|
+
if (findings.length === 0) return;
|
|
265
|
+
|
|
266
|
+
// Filter by cooldown
|
|
267
|
+
const actionable: PatternFinding[] = [];
|
|
268
|
+
for (const f of findings) {
|
|
269
|
+
if (!(await isOnCooldown(env.db, f.pattern))) {
|
|
270
|
+
actionable.push(f);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (actionable.length === 0) {
|
|
275
|
+
console.log(`[argus-heartbeat] ${findings.length} pattern(s) detected but all on cooldown`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Split: critical gets immediate email, rest goes to digest
|
|
280
|
+
const critical = actionable.filter(f => f.severity === 'critical');
|
|
281
|
+
const digestable = actionable.filter(f => f.severity !== 'critical');
|
|
282
|
+
|
|
283
|
+
if (critical.length > 0) {
|
|
284
|
+
try {
|
|
285
|
+
await sendPatternAlert(env, critical);
|
|
286
|
+
console.log(`[argus-heartbeat] Sent critical alert for ${critical.length} pattern(s)`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error('[argus-heartbeat] Alert send failed:', err instanceof Error ? err.message : err);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (digestable.length > 0) {
|
|
293
|
+
const payload = JSON.stringify({
|
|
294
|
+
type: 'argus_patterns',
|
|
295
|
+
patterns: digestable.map(f => ({
|
|
296
|
+
pattern: f.pattern,
|
|
297
|
+
severity: f.severity,
|
|
298
|
+
summary: f.summary,
|
|
299
|
+
detail: f.detail,
|
|
300
|
+
})),
|
|
301
|
+
timestamp: new Date().toISOString(),
|
|
302
|
+
});
|
|
303
|
+
await env.db.prepare(
|
|
304
|
+
"INSERT INTO digest_sections (section, payload) VALUES ('event_notification', ?)"
|
|
305
|
+
).bind(payload).run();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Record cooldowns for all actioned findings
|
|
309
|
+
for (const f of actionable) {
|
|
310
|
+
await recordCooldown(env.db, f.pattern);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log(`[argus-heartbeat] ${actionable.length} pattern(s) actioned: ${actionable.map(f => f.pattern).join(', ')}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function escapeHtml(s: string): string {
|
|
319
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
320
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// ARGUS Phase 2: Event notification processor
|
|
2
|
+
// Reads unnotified events from the events table, classifies priority,
|
|
3
|
+
// and routes to immediate email (critical) or daily digest (high/medium).
|
|
4
|
+
// Runs every hour as a scheduled task.
|
|
5
|
+
|
|
6
|
+
import { type EdgeEnv } from '../dispatch.js';
|
|
7
|
+
import { resolveEmailProfile } from '../../email.js';
|
|
8
|
+
import { operatorConfig } from '../../operator/index.js';
|
|
9
|
+
|
|
10
|
+
// ─── Priority Classification ────────────────────────────────
|
|
11
|
+
|
|
12
|
+
type Priority = 'critical' | 'high' | 'medium' | 'low';
|
|
13
|
+
|
|
14
|
+
interface ClassifiedEvent {
|
|
15
|
+
id: string;
|
|
16
|
+
source: string;
|
|
17
|
+
event_type: string;
|
|
18
|
+
payload: Record<string, unknown>;
|
|
19
|
+
ts: string;
|
|
20
|
+
priority: Priority;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Events that warrant immediate email
|
|
25
|
+
const CRITICAL_EVENTS: Record<string, Set<string>> = {
|
|
26
|
+
stripe: new Set([
|
|
27
|
+
'charge.failed',
|
|
28
|
+
'customer.subscription.deleted',
|
|
29
|
+
'invoice.payment_failed',
|
|
30
|
+
'payment_intent.payment_failed',
|
|
31
|
+
]),
|
|
32
|
+
github: new Set([
|
|
33
|
+
'check_run.completed', // only if conclusion=failure, refined below
|
|
34
|
+
]),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Events worth tracking in digest
|
|
38
|
+
const HIGH_EVENTS: Record<string, Set<string>> = {
|
|
39
|
+
stripe: new Set([
|
|
40
|
+
'checkout.session.completed',
|
|
41
|
+
'customer.subscription.created',
|
|
42
|
+
'customer.subscription.updated',
|
|
43
|
+
'invoice.paid',
|
|
44
|
+
'payment_intent.succeeded',
|
|
45
|
+
]),
|
|
46
|
+
github: new Set([
|
|
47
|
+
'pull_request.merged',
|
|
48
|
+
'pull_request.opened',
|
|
49
|
+
'issues.opened',
|
|
50
|
+
'release.published',
|
|
51
|
+
]),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Events to log but not notify
|
|
55
|
+
const MEDIUM_EVENTS: Record<string, Set<string>> = {
|
|
56
|
+
github: new Set([
|
|
57
|
+
'pull_request.closed',
|
|
58
|
+
'issues.closed',
|
|
59
|
+
'push',
|
|
60
|
+
'create', // branch/tag creation
|
|
61
|
+
]),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function classifyEvent(source: string, event_type: string, payload: Record<string, unknown>): { priority: Priority; summary: string } {
|
|
65
|
+
// GitHub push to auto/* branches with 0 commits = taskrunner branch cleanup noise
|
|
66
|
+
if (source === 'github' && event_type === 'push') {
|
|
67
|
+
const ref = (payload.ref as string) ?? '';
|
|
68
|
+
const commits = payload.commits as unknown[] | undefined;
|
|
69
|
+
if (ref.startsWith('refs/heads/auto/') && (!commits || commits.length === 0)) {
|
|
70
|
+
return { priority: 'low', summary: 'Auto-branch push (0 commits, filtered)' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// GitHub check_run failures are critical, successes are low
|
|
75
|
+
if (source === 'github' && event_type === 'check_run.completed') {
|
|
76
|
+
const conclusion = (payload.check_run as Record<string, unknown>)?.conclusion as string;
|
|
77
|
+
if (conclusion === 'failure') {
|
|
78
|
+
const name = (payload.check_run as Record<string, unknown>)?.name ?? 'CI';
|
|
79
|
+
const repo = (payload.repository as Record<string, unknown>)?.full_name ?? 'unknown';
|
|
80
|
+
return { priority: 'critical', summary: `CI failure: ${name} in ${repo}` };
|
|
81
|
+
}
|
|
82
|
+
return { priority: 'low', summary: 'CI check passed' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Critical events
|
|
86
|
+
if (CRITICAL_EVENTS[source]?.has(event_type)) {
|
|
87
|
+
return { priority: 'critical', summary: summarizeEvent(source, event_type, payload) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// High events
|
|
91
|
+
if (HIGH_EVENTS[source]?.has(event_type)) {
|
|
92
|
+
return { priority: 'high', summary: summarizeEvent(source, event_type, payload) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Medium events
|
|
96
|
+
if (MEDIUM_EVENTS[source]?.has(event_type)) {
|
|
97
|
+
return { priority: 'medium', summary: summarizeEvent(source, event_type, payload) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { priority: 'low', summary: summarizeEvent(source, event_type, payload) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function summarizeEvent(source: string, event_type: string, payload: Record<string, unknown>): string {
|
|
104
|
+
if (source === 'github') {
|
|
105
|
+
const repo = (payload.repository as Record<string, unknown>)?.full_name ?? '';
|
|
106
|
+
const pr = payload.pull_request as Record<string, unknown> | undefined;
|
|
107
|
+
const issue = payload.issue as Record<string, unknown> | undefined;
|
|
108
|
+
|
|
109
|
+
if (pr) return `${event_type}: ${pr.title} (#${pr.number}) in ${repo}`;
|
|
110
|
+
if (issue) return `${event_type}: ${issue.title} (#${issue.number}) in ${repo}`;
|
|
111
|
+
if (event_type === 'push') {
|
|
112
|
+
const ref = (payload.ref as string)?.replace('refs/heads/', '') ?? '';
|
|
113
|
+
const commits = (payload.commits as unknown[])?.length ?? 0;
|
|
114
|
+
return `push: ${commits} commit(s) to ${ref} in ${repo}`;
|
|
115
|
+
}
|
|
116
|
+
return `${event_type} in ${repo}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (source === 'stripe') {
|
|
120
|
+
const obj = payload.data as Record<string, unknown> | undefined;
|
|
121
|
+
const inner = obj?.object as Record<string, unknown> | undefined;
|
|
122
|
+
const amount = inner?.amount as number | undefined;
|
|
123
|
+
const currency = (inner?.currency as string)?.toUpperCase() ?? '';
|
|
124
|
+
if (amount !== undefined) {
|
|
125
|
+
return `${event_type}: ${(amount / 100).toFixed(2)} ${currency}`;
|
|
126
|
+
}
|
|
127
|
+
return event_type;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `${source}/${event_type}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Notification Routing ────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const ALERT_COOLDOWN_MS = 4 * 60 * 60 * 1000; // 4h cooldown per event type
|
|
136
|
+
|
|
137
|
+
async function shouldNotify(db: D1Database, source: string, event_type: string): Promise<boolean> {
|
|
138
|
+
const cooldownKey = `argus_alert_${source}_${event_type}`;
|
|
139
|
+
const last = await db.prepare(
|
|
140
|
+
"SELECT received_at FROM web_events WHERE event_id = ?"
|
|
141
|
+
).bind(cooldownKey).first<{ received_at: string }>();
|
|
142
|
+
|
|
143
|
+
if (last) {
|
|
144
|
+
const elapsed = Date.now() - new Date(last.received_at + 'Z').getTime();
|
|
145
|
+
if (elapsed < ALERT_COOLDOWN_MS) return false;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function recordCooldown(db: D1Database, source: string, event_type: string): Promise<void> {
|
|
151
|
+
const cooldownKey = `argus_alert_${source}_${event_type}`;
|
|
152
|
+
await db.prepare(
|
|
153
|
+
"INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES (?, datetime('now'))"
|
|
154
|
+
).bind(cooldownKey).run();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function sendCriticalAlert(env: EdgeEnv, events: ClassifiedEvent[]): Promise<void> {
|
|
158
|
+
if (!env.resendApiKey || !env.notifyEmail) return;
|
|
159
|
+
|
|
160
|
+
const sender = resolveEmailProfile(operatorConfig.integrations.email.defaultProfile, {
|
|
161
|
+
resendApiKey: env.resendApiKey,
|
|
162
|
+
resendApiKeyPersonal: env.resendApiKeyPersonal,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const eventRows = events.map(e => `
|
|
166
|
+
<tr>
|
|
167
|
+
<td style="padding:6px 12px;font-size:11px;color:#888;border-bottom:1px solid #1a1a2e">${e.source}</td>
|
|
168
|
+
<td style="padding:6px 12px;font-size:12px;color:#e0e0e0;border-bottom:1px solid #1a1a2e">${escapeHtml(e.event_type)}</td>
|
|
169
|
+
<td style="padding:6px 12px;font-size:12px;color:#ccc;border-bottom:1px solid #1a1a2e">${escapeHtml(e.summary)}</td>
|
|
170
|
+
<td style="padding:6px 12px;font-size:11px;color:#666;border-bottom:1px solid #1a1a2e">${e.ts.slice(0, 19)}</td>
|
|
171
|
+
</tr>`).join('');
|
|
172
|
+
|
|
173
|
+
const html = `<!DOCTYPE html>
|
|
174
|
+
<html><head><meta charset="utf-8"></head>
|
|
175
|
+
<body style="background:#0a0a0f;color:#e0e0e0;font-family:system-ui,sans-serif;margin:0;padding:24px">
|
|
176
|
+
<div style="max-width:640px;margin:0 auto">
|
|
177
|
+
<div style="background:#1a1a2e;border:1px solid #333;border-left:4px solid #ef4444;border-radius:4px;padding:16px 20px;margin-bottom:20px">
|
|
178
|
+
<p style="margin:0 0 2px;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px">ARGUS Alert</p>
|
|
179
|
+
<p style="margin:0;font-size:16px;font-weight:600;color:#ef4444">${events.length} Critical Event${events.length > 1 ? 's' : ''}</p>
|
|
180
|
+
</div>
|
|
181
|
+
<table style="width:100%;border-collapse:collapse;background:#111;border:1px solid #222;border-radius:6px">
|
|
182
|
+
<thead><tr>
|
|
183
|
+
<th style="padding:8px 12px;font-size:10px;color:#666;text-align:left;text-transform:uppercase;border-bottom:1px solid #333">Source</th>
|
|
184
|
+
<th style="padding:8px 12px;font-size:10px;color:#666;text-align:left;text-transform:uppercase;border-bottom:1px solid #333">Event</th>
|
|
185
|
+
<th style="padding:8px 12px;font-size:10px;color:#666;text-align:left;text-transform:uppercase;border-bottom:1px solid #333">Summary</th>
|
|
186
|
+
<th style="padding:8px 12px;font-size:10px;color:#666;text-align:left;text-transform:uppercase;border-bottom:1px solid #333">Time</th>
|
|
187
|
+
</tr></thead>
|
|
188
|
+
<tbody>${eventRows}</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
<p style="margin:16px 0 0;font-size:11px;color:#444">aegis · argus · ${new Date().toISOString()}</p>
|
|
191
|
+
</div></body></html>`;
|
|
192
|
+
|
|
193
|
+
const subject = events.length === 1
|
|
194
|
+
? `[ARGUS] ${events[0].summary}`
|
|
195
|
+
: `[ARGUS] ${events.length} critical events — ${events[0].summary}`;
|
|
196
|
+
|
|
197
|
+
await fetch('https://api.resend.com/emails', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sender.apiKey}` },
|
|
200
|
+
body: JSON.stringify({ from: sender.from, to: [env.notifyEmail], subject, html }),
|
|
201
|
+
signal: AbortSignal.timeout(10_000),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function queueForDigest(db: D1Database, events: ClassifiedEvent[]): Promise<void> {
|
|
206
|
+
if (events.length === 0) return;
|
|
207
|
+
|
|
208
|
+
const payload = JSON.stringify({
|
|
209
|
+
type: 'argus_events',
|
|
210
|
+
events: events.map(e => ({
|
|
211
|
+
source: e.source,
|
|
212
|
+
event_type: e.event_type,
|
|
213
|
+
summary: e.summary,
|
|
214
|
+
priority: e.priority,
|
|
215
|
+
ts: e.ts,
|
|
216
|
+
})),
|
|
217
|
+
timestamp: new Date().toISOString(),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await db.prepare(
|
|
221
|
+
"INSERT INTO digest_sections (section, payload) VALUES ('event_notification', ?)"
|
|
222
|
+
).bind(payload).run();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Main Processor ──────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export async function runArgusNotifications(env: EdgeEnv): Promise<void> {
|
|
228
|
+
// Fetch unprocessed events (batch of 50)
|
|
229
|
+
const result = await env.db.prepare(
|
|
230
|
+
"SELECT id, source, event_type, payload, ts FROM events WHERE notified = 0 ORDER BY ts ASC LIMIT 50"
|
|
231
|
+
).all<{ id: string; source: string; event_type: string; payload: string; ts: string }>();
|
|
232
|
+
|
|
233
|
+
const rows = result.results;
|
|
234
|
+
if (rows.length === 0) return;
|
|
235
|
+
|
|
236
|
+
// Classify all events. Stripe test-mode events are dropped pre-classification so
|
|
237
|
+
// they never land in digest_sections — real MRR is livemode=true only. Rows still
|
|
238
|
+
// get marked notified=1 below so we don't re-scan them on the next run.
|
|
239
|
+
const classified: ClassifiedEvent[] = rows.flatMap(row => {
|
|
240
|
+
let payload: Record<string, unknown> = {};
|
|
241
|
+
try { payload = JSON.parse(row.payload); } catch { /* use empty */ }
|
|
242
|
+
if (row.source === 'stripe' && payload.livemode === false) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const { priority, summary } = classifyEvent(row.source, row.event_type, payload);
|
|
246
|
+
return [{ id: row.id, source: row.source, event_type: row.event_type, payload, ts: row.ts, priority, summary }];
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Split by priority
|
|
250
|
+
const critical: ClassifiedEvent[] = [];
|
|
251
|
+
const digestable: ClassifiedEvent[] = [];
|
|
252
|
+
|
|
253
|
+
for (const event of classified) {
|
|
254
|
+
if (event.priority === 'critical') {
|
|
255
|
+
const canNotify = await shouldNotify(env.db, event.source, event.event_type);
|
|
256
|
+
if (canNotify) critical.push(event);
|
|
257
|
+
} else if (event.priority === 'high' || event.priority === 'medium') {
|
|
258
|
+
digestable.push(event);
|
|
259
|
+
}
|
|
260
|
+
// low priority: skip notification entirely, just mark as notified
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Send critical alerts immediately
|
|
264
|
+
if (critical.length > 0) {
|
|
265
|
+
try {
|
|
266
|
+
await sendCriticalAlert(env, critical);
|
|
267
|
+
for (const e of critical) {
|
|
268
|
+
await recordCooldown(env.db, e.source, e.event_type);
|
|
269
|
+
}
|
|
270
|
+
console.log(`[argus] Sent critical alert for ${critical.length} event(s)`);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
console.error('[argus] Critical alert send failed:', err instanceof Error ? err.message : err);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Queue high/medium for daily digest
|
|
277
|
+
if (digestable.length > 0) {
|
|
278
|
+
try {
|
|
279
|
+
await queueForDigest(env.db, digestable);
|
|
280
|
+
console.log(`[argus] Queued ${digestable.length} event(s) for daily digest`);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error('[argus] Digest queue failed:', err instanceof Error ? err.message : err);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Mark all events as notified (including low-priority ones)
|
|
287
|
+
const ids = rows.map(r => r.id);
|
|
288
|
+
// D1 doesn't support IN with bind params easily — batch update
|
|
289
|
+
for (const id of ids) {
|
|
290
|
+
await env.db.prepare("UPDATE events SET notified = 1 WHERE id = ?").bind(id).run();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const summary = [
|
|
294
|
+
critical.length > 0 ? `${critical.length} critical` : null,
|
|
295
|
+
digestable.length > 0 ? `${digestable.length} digest` : null,
|
|
296
|
+
`${ids.length - critical.length - digestable.length} suppressed`,
|
|
297
|
+
].filter(Boolean).join(', ');
|
|
298
|
+
console.log(`[argus] Processed ${ids.length} events: ${summary}`);
|
|
299
|
+
|
|
300
|
+
// ── Prune old events (daily, piggybacks on hourly run) ──
|
|
301
|
+
await pruneOldEvents(env.db);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Event Pruning ───────────────────────────────────────────
|
|
305
|
+
// Deletes notified events older than 30 days. Runs at most once per 24h.
|
|
306
|
+
|
|
307
|
+
const RETENTION_DAYS = 30;
|
|
308
|
+
const PRUNE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
309
|
+
|
|
310
|
+
async function pruneOldEvents(db: D1Database): Promise<void> {
|
|
311
|
+
const lastPrune = await db.prepare(
|
|
312
|
+
"SELECT received_at FROM web_events WHERE event_id = 'argus_prune'"
|
|
313
|
+
).first<{ received_at: string }>();
|
|
314
|
+
|
|
315
|
+
if (lastPrune) {
|
|
316
|
+
const elapsed = Date.now() - new Date(lastPrune.received_at + 'Z').getTime();
|
|
317
|
+
if (elapsed < PRUNE_COOLDOWN_MS) return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const result = await db.prepare(
|
|
321
|
+
`DELETE FROM events WHERE notified = 1 AND ts < datetime('now', '-${RETENTION_DAYS} days')`
|
|
322
|
+
).run();
|
|
323
|
+
|
|
324
|
+
const pruned = result.meta?.changes ?? 0;
|
|
325
|
+
if (pruned > 0) {
|
|
326
|
+
console.log(`[argus] Pruned ${pruned} events older than ${RETENTION_DAYS}d`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Also prune consumed digest_sections older than 7 days
|
|
330
|
+
await db.prepare(
|
|
331
|
+
"DELETE FROM digest_sections WHERE consumed = 1 AND created_at < datetime('now', '-7 days')"
|
|
332
|
+
).run();
|
|
333
|
+
|
|
334
|
+
// Also prune stale ARGUS cooldown entries from web_events (older than 30d)
|
|
335
|
+
await db.prepare(
|
|
336
|
+
"DELETE FROM web_events WHERE event_id LIKE 'argus_%' AND received_at < datetime('now', '-30 days')"
|
|
337
|
+
).run();
|
|
338
|
+
|
|
339
|
+
await db.prepare(
|
|
340
|
+
"INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('argus_prune', datetime('now'))"
|
|
341
|
+
).run();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
function escapeHtml(s: string): string {
|
|
347
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
348
|
+
}
|