@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.
Files changed (148) hide show
  1. package/package.json +96 -0
  2. package/schema.sql +586 -0
  3. package/src/adapters/voice/cloudflare-agent.ts +34 -0
  4. package/src/auth.ts +124 -0
  5. package/src/bluesky.ts +464 -0
  6. package/src/claude-tools/content.ts +188 -0
  7. package/src/claude-tools/email.ts +69 -0
  8. package/src/claude-tools/github.ts +440 -0
  9. package/src/claude-tools/goals.ts +116 -0
  10. package/src/claude-tools/index.ts +353 -0
  11. package/src/claude-tools/web.ts +59 -0
  12. package/src/claude.ts +406 -0
  13. package/src/codebeast.ts +200 -0
  14. package/src/composite.ts +715 -0
  15. package/src/content/column.ts +80 -0
  16. package/src/content/hero-image.ts +47 -0
  17. package/src/content/index.ts +27 -0
  18. package/src/content/journal.ts +91 -0
  19. package/src/content/roundtable.ts +163 -0
  20. package/src/core.ts +309 -0
  21. package/src/dashboard.ts +620 -0
  22. package/src/decision-docs.ts +284 -0
  23. package/src/dispatch.ts +13 -0
  24. package/src/edge-env.ts +58 -0
  25. package/src/email.ts +850 -0
  26. package/src/exports.ts +156 -0
  27. package/src/github-projects.ts +312 -0
  28. package/src/github.ts +670 -0
  29. package/src/groq.ts +247 -0
  30. package/src/health-page.ts +578 -0
  31. package/src/index.ts +89 -0
  32. package/src/kernel/argus-actions.ts +397 -0
  33. package/src/kernel/argus-correlation.ts +639 -0
  34. package/src/kernel/board.ts +91 -0
  35. package/src/kernel/briefing.ts +177 -0
  36. package/src/kernel/classify-memory-topic.ts +166 -0
  37. package/src/kernel/cognition.ts +377 -0
  38. package/src/kernel/court-cards.ts +163 -0
  39. package/src/kernel/dispatch.ts +587 -0
  40. package/src/kernel/domain.ts +50 -0
  41. package/src/kernel/dynamic-tools.ts +322 -0
  42. package/src/kernel/executor-port.ts +45 -0
  43. package/src/kernel/executors/claude.ts +73 -0
  44. package/src/kernel/executors/direct.ts +237 -0
  45. package/src/kernel/executors/groq.ts +18 -0
  46. package/src/kernel/executors/index.ts +87 -0
  47. package/src/kernel/executors/tarotscript.ts +104 -0
  48. package/src/kernel/executors/workers-ai.ts +54 -0
  49. package/src/kernel/insight-cache.ts +76 -0
  50. package/src/kernel/memory/agenda.ts +200 -0
  51. package/src/kernel/memory/blocks.ts +188 -0
  52. package/src/kernel/memory/consolidation.ts +194 -0
  53. package/src/kernel/memory/episodic.ts +241 -0
  54. package/src/kernel/memory/goals.ts +156 -0
  55. package/src/kernel/memory/graph.ts +290 -0
  56. package/src/kernel/memory/index.ts +11 -0
  57. package/src/kernel/memory/insights.ts +316 -0
  58. package/src/kernel/memory/procedural.ts +467 -0
  59. package/src/kernel/memory/pruning.ts +67 -0
  60. package/src/kernel/memory/recall.ts +367 -0
  61. package/src/kernel/memory/semantic.ts +315 -0
  62. package/src/kernel/memory/synthesis.ts +161 -0
  63. package/src/kernel/memory-adapter.ts +369 -0
  64. package/src/kernel/memory-guardrails.ts +76 -0
  65. package/src/kernel/port.ts +23 -0
  66. package/src/kernel/resilience.ts +322 -0
  67. package/src/kernel/router.ts +471 -0
  68. package/src/kernel/scheduled/agent-dispatch.ts +252 -0
  69. package/src/kernel/scheduled/argus-analytics.ts +247 -0
  70. package/src/kernel/scheduled/argus-heartbeat.ts +320 -0
  71. package/src/kernel/scheduled/argus-notify.ts +348 -0
  72. package/src/kernel/scheduled/board-sync.ts +110 -0
  73. package/src/kernel/scheduled/ci-watcher.ts +125 -0
  74. package/src/kernel/scheduled/cognitive-metrics.ts +377 -0
  75. package/src/kernel/scheduled/consolidation.ts +229 -0
  76. package/src/kernel/scheduled/content-drip.ts +47 -0
  77. package/src/kernel/scheduled/content.ts +6 -0
  78. package/src/kernel/scheduled/conversation-facts.ts +204 -0
  79. package/src/kernel/scheduled/cost-report.ts +84 -0
  80. package/src/kernel/scheduled/curiosity.ts +219 -0
  81. package/src/kernel/scheduled/dev-activity.ts +44 -0
  82. package/src/kernel/scheduled/digest.ts +317 -0
  83. package/src/kernel/scheduled/dreaming/agenda-triage.ts +115 -0
  84. package/src/kernel/scheduled/dreaming/facts.ts +239 -0
  85. package/src/kernel/scheduled/dreaming/index.ts +8 -0
  86. package/src/kernel/scheduled/dreaming/llm.ts +33 -0
  87. package/src/kernel/scheduled/dreaming/pattern-synthesis.ts +124 -0
  88. package/src/kernel/scheduled/dreaming/persona.ts +75 -0
  89. package/src/kernel/scheduled/dreaming/symbolic.ts +31 -0
  90. package/src/kernel/scheduled/dreaming/task-proposals.ts +80 -0
  91. package/src/kernel/scheduled/dreaming.ts +66 -0
  92. package/src/kernel/scheduled/entropy.ts +149 -0
  93. package/src/kernel/scheduled/escalation.ts +192 -0
  94. package/src/kernel/scheduled/feed-watcher.ts +206 -0
  95. package/src/kernel/scheduled/goals.ts +214 -0
  96. package/src/kernel/scheduled/governance.ts +41 -0
  97. package/src/kernel/scheduled/heartbeat.ts +220 -0
  98. package/src/kernel/scheduled/inbox-processor.ts +174 -0
  99. package/src/kernel/scheduled/index.ts +245 -0
  100. package/src/kernel/scheduled/issue-proposer.ts +478 -0
  101. package/src/kernel/scheduled/issue-watcher.ts +128 -0
  102. package/src/kernel/scheduled/pr-automerge.ts +213 -0
  103. package/src/kernel/scheduled/product-health.ts +107 -0
  104. package/src/kernel/scheduled/reflection.ts +373 -0
  105. package/src/kernel/scheduled/self-improvement.ts +114 -0
  106. package/src/kernel/scheduled/social-engage.ts +175 -0
  107. package/src/kernel/scheduled/task-audit.ts +60 -0
  108. package/src/kernel/symbolic.ts +156 -0
  109. package/src/kernel/types.ts +145 -0
  110. package/src/landing.ts +1190 -0
  111. package/src/lib/audit-chain/chain.ts +28 -0
  112. package/src/lib/audit-chain/types.ts +12 -0
  113. package/src/lib/observability/errors.ts +55 -0
  114. package/src/markdown.ts +164 -0
  115. package/src/mcp/handlers.ts +647 -0
  116. package/src/mcp/server.ts +184 -0
  117. package/src/mcp/tools.ts +316 -0
  118. package/src/mcp-client.ts +275 -0
  119. package/src/mcp-server.ts +2 -0
  120. package/src/operator/config.example.ts +60 -0
  121. package/src/operator/config.ts +60 -0
  122. package/src/operator/index.ts +46 -0
  123. package/src/operator/persona.example.ts +34 -0
  124. package/src/operator/persona.ts +34 -0
  125. package/src/operator/prompt-builder.ts +190 -0
  126. package/src/operator/types.ts +43 -0
  127. package/src/pulse.ts +1179 -0
  128. package/src/routes/bluesky.ts +116 -0
  129. package/src/routes/cc-tasks.ts +328 -0
  130. package/src/routes/codebeast.ts +1 -0
  131. package/src/routes/content.ts +194 -0
  132. package/src/routes/conversations.ts +25 -0
  133. package/src/routes/dynamic-tools.ts +111 -0
  134. package/src/routes/feedback.ts +192 -0
  135. package/src/routes/health.ts +147 -0
  136. package/src/routes/messages.ts +228 -0
  137. package/src/routes/observability.ts +82 -0
  138. package/src/routes/operator-logs.ts +42 -0
  139. package/src/routes/pages.ts +96 -0
  140. package/src/routes/sessions.ts +54 -0
  141. package/src/sanitize.ts +73 -0
  142. package/src/schema-enums.ts +155 -0
  143. package/src/search.ts +112 -0
  144. package/src/task-intelligence.ts +497 -0
  145. package/src/types.ts +194 -0
  146. package/src/ui.ts +5 -0
  147. package/src/version.ts +3 -0
  148. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
348
+ }