@littlebearapps/create-platform 1.0.0 → 1.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 (69) hide show
  1. package/README.md +98 -0
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +36 -6
  4. package/dist/prompts.d.ts +14 -2
  5. package/dist/prompts.js +29 -7
  6. package/dist/templates.js +78 -0
  7. package/package.json +3 -2
  8. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  9. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  10. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  11. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  12. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  13. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  14. package/templates/full/workers/pattern-discovery.ts +661 -0
  15. package/templates/full/workers/platform-alert-router.ts +1809 -0
  16. package/templates/full/workers/platform-notifications.ts +424 -0
  17. package/templates/full/workers/platform-search.ts +480 -0
  18. package/templates/full/workers/platform-settings.ts +436 -0
  19. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  20. package/templates/shared/workers/lib/billing.ts +293 -0
  21. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  22. package/templates/shared/workers/lib/control.ts +292 -0
  23. package/templates/shared/workers/lib/economics.ts +368 -0
  24. package/templates/shared/workers/lib/metrics.ts +103 -0
  25. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  26. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  27. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  28. package/templates/shared/workers/lib/shared/types.ts +58 -0
  29. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  30. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  31. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  32. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  33. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  34. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  35. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  36. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  37. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  38. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  39. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  40. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  41. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  42. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  43. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  44. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  45. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  46. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  47. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  48. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  49. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  50. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  51. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  52. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  53. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  54. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  55. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  56. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  57. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  58. package/templates/shared/workers/platform-usage.ts +1915 -0
  59. package/templates/standard/workers/error-collector.ts +2670 -0
  60. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  61. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  62. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  63. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  64. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  65. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  66. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  67. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  68. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  69. package/templates/standard/workers/platform-sentinel.ts +1744 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Pattern Discovery Worker
3
+ *
4
+ * AI-assisted discovery of transient error patterns.
5
+ * Analyses high-frequency unclassified errors and suggests
6
+ * regex patterns for human approval.
7
+ *
8
+ * Schedule: Daily at 2:00 AM UTC
9
+ *
10
+ * @module workers/pattern-discovery
11
+ * @created 2026-02-02
12
+ */
13
+
14
+ import type {
15
+ KVNamespace,
16
+ ExecutionContext,
17
+ ScheduledEvent,
18
+ D1Database,
19
+ } from '@cloudflare/workers-types';
20
+ import {
21
+ withCronBudget,
22
+ CircuitBreakerError,
23
+ completeTracking,
24
+ createLogger,
25
+ createLoggerFromRequest,
26
+ health,
27
+ MONITOR_PATTERN_DISCOVERY,
28
+ HEARTBEAT_HEALTH,
29
+ } from '@littlebearapps/platform-sdk';
30
+ import {
31
+ queryUnclassifiedErrors,
32
+ clusterErrors,
33
+ buildClusterObjects,
34
+ getSampleMessages,
35
+ storeClusters,
36
+ getPendingClusters,
37
+ updateClusterStatus,
38
+ MAX_SAMPLES_PER_CLUSTER,
39
+ } from './lib/pattern-discovery/clustering';
40
+ import {
41
+ suggestPatterns,
42
+ evaluateStaticPatterns,
43
+ type StaticPatternInput,
44
+ } from './lib/pattern-discovery/ai-prompt';
45
+ import { TRANSIENT_ERROR_PATTERNS } from './lib/error-collector/fingerprint';
46
+ import {
47
+ validatePatternSafety,
48
+ backtestPattern,
49
+ storeBacktestResult,
50
+ compilePattern,
51
+ } from './lib/pattern-discovery/validation';
52
+ import {
53
+ storePatternSuggestions,
54
+ getPendingSuggestions,
55
+ approveSuggestion,
56
+ rejectSuggestion,
57
+ refreshDynamicPatternsCache,
58
+ getApprovedPatterns,
59
+ getPatternStats,
60
+ } from './lib/pattern-discovery/storage';
61
+ import {
62
+ runShadowEvaluationCycle,
63
+ enterShadowMode,
64
+ DEFAULT_EVALUATION_CONFIG,
65
+ type AIContextEnv,
66
+ } from './lib/pattern-discovery/shadow-evaluation';
67
+ import type { DiscoveryResult, ErrorCluster, PatternRule } from './lib/pattern-discovery/types';
68
+ import { pingHeartbeat } from '@littlebearapps/platform-sdk';
69
+
70
+ // =============================================================================
71
+ // TYPES
72
+ // =============================================================================
73
+
74
+ interface Env {
75
+ PLATFORM_DB: D1Database;
76
+ PLATFORM_CACHE: KVNamespace;
77
+ PLATFORM_TELEMETRY: Queue;
78
+ NOTIFICATIONS_API?: Fetcher; // Optional: for creating dashboard notifications
79
+ PLATFORM_AI_GATEWAY_KEY: string;
80
+ CLOUDFLARE_ACCOUNT_ID: string;
81
+ GATUS_HEARTBEAT_URL?: string; // Gatus heartbeat ping URL for cron monitoring
82
+ GATUS_TOKEN?: string; // Bearer token for Gatus external endpoints
83
+ }
84
+
85
+ /**
86
+ * Create a dashboard notification for new pattern suggestions.
87
+ * Non-blocking - failures are logged but don't affect discovery.
88
+ */
89
+ async function createPatternNotification(
90
+ api: Fetcher | undefined,
91
+ suggestionsCount: number
92
+ ): Promise<void> {
93
+ if (!api || suggestionsCount === 0) return;
94
+
95
+ try {
96
+ await api.fetch('https://platform-notifications/notifications', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({
100
+ category: 'info',
101
+ source: 'pattern-discovery',
102
+ title: `${suggestionsCount} new pattern suggestion${suggestionsCount !== 1 ? 's' : ''} pending review`,
103
+ description: `AI discovered ${suggestionsCount} potential transient error pattern${suggestionsCount !== 1 ? 's' : ''}. Review and approve in the Pattern Discovery dashboard.`,
104
+ priority: 'low',
105
+ action_url: '/patterns',
106
+ action_label: 'Review Patterns',
107
+ project: 'platform',
108
+ }),
109
+ });
110
+ } catch (e) {
111
+ // Non-blocking - log and continue
112
+ console.error('Failed to create pattern notification:', e);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Create a dashboard notification when shadow patterns are ready for human review.
118
+ * Higher priority than discovery notifications since these need action.
119
+ */
120
+ async function createReviewNotification(
121
+ api: Fetcher | undefined,
122
+ readyCount: number
123
+ ): Promise<void> {
124
+ if (!api || readyCount === 0) return;
125
+
126
+ try {
127
+ await api.fetch('https://platform-notifications/notifications', {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({
131
+ category: 'warning',
132
+ source: 'pattern-discovery',
133
+ title: `${readyCount} pattern${readyCount !== 1 ? 's' : ''} ready for your review`,
134
+ description: `Shadow evaluation completed. ${readyCount} pattern${readyCount !== 1 ? 's have' : ' has'} collected enough evidence and need${readyCount === 1 ? 's' : ''} human review before approval.`,
135
+ priority: 'medium',
136
+ action_url: '/patterns',
137
+ action_label: 'Review Patterns',
138
+ project: 'platform',
139
+ }),
140
+ });
141
+ } catch (e) {
142
+ console.error('Failed to create review notification:', e);
143
+ }
144
+ }
145
+
146
+ // =============================================================================
147
+ // FEATURE ID
148
+ // =============================================================================
149
+
150
+ const FEATURE_ID = MONITOR_PATTERN_DISCOVERY;
151
+
152
+ // =============================================================================
153
+ // MAIN WORKER
154
+ // =============================================================================
155
+
156
+ export default {
157
+ /**
158
+ * Scheduled handler - runs daily pattern discovery and shadow evaluation
159
+ *
160
+ * Cron schedule: 0 2 * * * (2:00 AM UTC)
161
+ * - Pattern discovery: Find new patterns from error clusters
162
+ * - Shadow evaluation: Auto-promote/demote patterns based on performance
163
+ */
164
+ async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
165
+ const log = createLogger({ worker: 'pattern-discovery', featureId: FEATURE_ID });
166
+ log.info('Pattern discovery triggered', {
167
+ scheduled_time: new Date(event.scheduledTime).toISOString(),
168
+ });
169
+
170
+ // Gatus heartbeat is pinged on success/fail only (no /start support)
171
+
172
+ try {
173
+ const trackedEnv = withCronBudget(env, FEATURE_ID, {
174
+ ctx,
175
+ cronExpression: '0 2 * * *', // Daily at 2:00 AM UTC
176
+ });
177
+
178
+ // Step 1: Run pattern discovery
179
+ const discoveryResult = await runDiscovery(env, log);
180
+
181
+ // Create dashboard notification if suggestions were created
182
+ if (discoveryResult.suggestionsCreated > 0) {
183
+ ctx.waitUntil(createPatternNotification(env.NOTIFICATIONS_API, discoveryResult.suggestionsCreated));
184
+ }
185
+
186
+ // Step 2: Run shadow evaluation cycle (marks patterns ready for review, auto-demotes stale)
187
+ // Pass env for AI explainer generation
188
+ const aiEnv: AIContextEnv = {
189
+ CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID,
190
+ PLATFORM_AI_GATEWAY_KEY: env.PLATFORM_AI_GATEWAY_KEY,
191
+ };
192
+ const evaluationResult = await runShadowEvaluationCycle(
193
+ env.PLATFORM_DB,
194
+ env.PLATFORM_CACHE,
195
+ log,
196
+ DEFAULT_EVALUATION_CONFIG,
197
+ aiEnv
198
+ );
199
+
200
+ // Notify when patterns are ready for human review
201
+ if (evaluationResult.readyForReview > 0) {
202
+ ctx.waitUntil(createReviewNotification(env.NOTIFICATIONS_API, evaluationResult.readyForReview));
203
+ }
204
+
205
+ // Refresh KV cache of approved patterns so error-collector always has latest
206
+ await refreshDynamicPatternsCache(env.PLATFORM_DB, env.PLATFORM_CACHE, log);
207
+
208
+ // Send heartbeat
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ await health(HEARTBEAT_HEALTH, env.PLATFORM_CACHE as any, env.PLATFORM_TELEMETRY, ctx);
211
+ await completeTracking(trackedEnv);
212
+
213
+ // Signal success to Gatus heartbeat
214
+ pingHeartbeat(ctx, env.GATUS_HEARTBEAT_URL, env.GATUS_TOKEN, true);
215
+
216
+ log.info('Pattern discovery and evaluation complete', {
217
+ discovery: {
218
+ runId: discoveryResult.runId,
219
+ clustersFound: discoveryResult.clustersFound,
220
+ suggestionsCreated: discoveryResult.suggestionsCreated,
221
+ errors: discoveryResult.errors.length,
222
+ },
223
+ evaluation: evaluationResult,
224
+ });
225
+ } catch (error) {
226
+ if (error instanceof CircuitBreakerError) {
227
+ log.warn('Circuit breaker STOP', error, { reason: error.reason });
228
+ return;
229
+ }
230
+
231
+ // Signal failure to Gatus heartbeat
232
+ pingHeartbeat(ctx, env.GATUS_HEARTBEAT_URL, env.GATUS_TOKEN, false);
233
+
234
+ log.error('Pattern discovery failed', error);
235
+ }
236
+ },
237
+
238
+ /**
239
+ * HTTP handler for manual triggers and API endpoints
240
+ */
241
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
242
+ const url = new URL(request.url);
243
+
244
+ // Health check (lightweight, no SDK overhead)
245
+ if (url.pathname === '/health') {
246
+ return Response.json({
247
+ status: 'ok',
248
+ service: 'pattern-discovery',
249
+ timestamp: new Date().toISOString(),
250
+ });
251
+ }
252
+
253
+ const log = createLoggerFromRequest(request, env, 'pattern-discovery', FEATURE_ID);
254
+
255
+ try {
256
+ // Manual discovery trigger
257
+ if (url.pathname === '/discover' && request.method === 'GET') {
258
+ const result = await runDiscovery(env, log);
259
+ return Response.json(result);
260
+ }
261
+
262
+ // Get pending suggestions
263
+ if (url.pathname === '/suggestions' && request.method === 'GET') {
264
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
265
+ const suggestions = await getPendingSuggestions(env.PLATFORM_DB, limit);
266
+ return Response.json({ suggestions, count: suggestions.length });
267
+ }
268
+
269
+ // Get shadow patterns ready for human review (have review_context)
270
+ if (url.pathname === '/ready-for-review' && request.method === 'GET') {
271
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
272
+ // Query shadow patterns that have review_context populated
273
+ const result = await env.PLATFORM_DB
274
+ .prepare(`
275
+ SELECT
276
+ id, pattern_type as patternType, pattern_value as patternValue,
277
+ category, scope, confidence_score as confidenceScore,
278
+ sample_messages as sampleMessages, ai_reasoning as aiReasoning,
279
+ cluster_id as clusterId, status,
280
+ reviewed_by as reviewedBy, reviewed_at as reviewedAt,
281
+ rejection_reason as rejectionReason,
282
+ backtest_match_count as backtestMatchCount,
283
+ backtest_total_errors as backtestTotalErrors,
284
+ backtest_match_rate as backtestMatchRate,
285
+ shadow_mode_start as shadowModeStart,
286
+ shadow_mode_end as shadowModeEnd,
287
+ shadow_mode_matches as shadowModeMatches,
288
+ shadow_match_days as shadowMatchDays,
289
+ enabled_at as enabledAt, disabled_at as disabledAt,
290
+ last_matched_at as lastMatchedAt, match_count as matchCount,
291
+ is_protected as isProtected, source, original_regex as originalRegex,
292
+ review_context as reviewContext,
293
+ created_at as createdAt, updated_at as updatedAt
294
+ FROM transient_pattern_suggestions
295
+ WHERE status = 'shadow' AND review_context IS NOT NULL
296
+ ORDER BY
297
+ json_extract(review_context, '$.totalMatches') DESC,
298
+ created_at ASC
299
+ LIMIT ?
300
+ `)
301
+ .bind(limit)
302
+ .all();
303
+
304
+ const suggestions = result.results.map((r: Record<string, unknown>) => ({
305
+ ...r,
306
+ sampleMessages: typeof r.sampleMessages === 'string' ? JSON.parse(r.sampleMessages as string) : [],
307
+ shadowMatchDays: typeof r.shadowMatchDays === 'string' && r.shadowMatchDays ? JSON.parse(r.shadowMatchDays as string) : [],
308
+ reviewContext: typeof r.reviewContext === 'string' && r.reviewContext ? JSON.parse(r.reviewContext as string) : null,
309
+ isProtected: Boolean(r.isProtected),
310
+ }));
311
+
312
+ return Response.json({ suggestions, count: suggestions.length });
313
+ }
314
+
315
+ // Approve a suggestion
316
+ if (url.pathname.startsWith('/suggestions/') && request.method === 'POST') {
317
+ const id = url.pathname.split('/').pop();
318
+ const action = url.searchParams.get('action');
319
+
320
+ if (!id) {
321
+ return Response.json({ error: 'Missing suggestion ID' }, { status: 400 });
322
+ }
323
+
324
+ if (action === 'approve') {
325
+ const reviewedBy = url.searchParams.get('by') || 'api';
326
+
327
+ // Run backtest first
328
+ const suggestion = (await getPendingSuggestions(env.PLATFORM_DB, 100)).find(
329
+ (s) => s.id === id
330
+ );
331
+ if (!suggestion) {
332
+ return Response.json({ error: 'Suggestion not found' }, { status: 404 });
333
+ }
334
+
335
+ const rule: PatternRule = {
336
+ type: suggestion.patternType,
337
+ value: suggestion.patternValue,
338
+ category: suggestion.category,
339
+ scope: suggestion.scope as PatternRule['scope'],
340
+ };
341
+
342
+ // Validate safety
343
+ const safetyError = validatePatternSafety(suggestion.patternType, suggestion.patternValue);
344
+ if (safetyError) {
345
+ return Response.json({ error: `Safety check failed: ${safetyError}` }, { status: 400 });
346
+ }
347
+
348
+ // Run backtest
349
+ const backtestResult = await backtestPattern(id, rule, env.PLATFORM_DB, log);
350
+ await storeBacktestResult(env.PLATFORM_DB, backtestResult, log);
351
+
352
+ if (backtestResult.overMatching) {
353
+ return Response.json(
354
+ {
355
+ error: 'Pattern over-matches',
356
+ matchRate: backtestResult.matchRate,
357
+ threshold: 0.8,
358
+ },
359
+ { status: 400 }
360
+ );
361
+ }
362
+
363
+ // Approve
364
+ const success = await approveSuggestion(
365
+ env.PLATFORM_DB,
366
+ env.PLATFORM_CACHE,
367
+ id,
368
+ reviewedBy,
369
+ log
370
+ );
371
+ return Response.json({ success, action: 'approved' });
372
+ }
373
+
374
+ if (action === 'reject') {
375
+ const reviewedBy = url.searchParams.get('by') || 'api';
376
+ const reason = url.searchParams.get('reason') || 'Rejected via API';
377
+ const success = await rejectSuggestion(env.PLATFORM_DB, id, reviewedBy, reason, log);
378
+ return Response.json({ success, action: 'rejected' });
379
+ }
380
+
381
+ return Response.json({ error: 'Invalid action' }, { status: 400 });
382
+ }
383
+
384
+ // Refresh KV cache
385
+ if (url.pathname === '/cache/refresh' && request.method === 'POST') {
386
+ await refreshDynamicPatternsCache(env.PLATFORM_DB, env.PLATFORM_CACHE, log);
387
+ return Response.json({ status: 'refreshed' });
388
+ }
389
+
390
+ // List approved patterns with match stats
391
+ if (url.pathname === '/patterns' && request.method === 'GET') {
392
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
393
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
394
+ const patterns = await getApprovedPatterns(env.PLATFORM_DB, limit, offset);
395
+ return Response.json({ patterns, count: patterns.length, limit, offset });
396
+ }
397
+
398
+ // Pattern stats summary
399
+ if (url.pathname === '/patterns/stats' && request.method === 'GET') {
400
+ const stats = await getPatternStats(env.PLATFORM_DB);
401
+ return Response.json(stats);
402
+ }
403
+
404
+ // Run shadow evaluation manually
405
+ if (url.pathname === '/evaluate-shadow' && request.method === 'GET') {
406
+ const aiEnv: AIContextEnv = {
407
+ CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID,
408
+ PLATFORM_AI_GATEWAY_KEY: env.PLATFORM_AI_GATEWAY_KEY,
409
+ };
410
+ const result = await runShadowEvaluationCycle(
411
+ env.PLATFORM_DB,
412
+ env.PLATFORM_CACHE,
413
+ log,
414
+ DEFAULT_EVALUATION_CONFIG,
415
+ aiEnv
416
+ );
417
+ return Response.json(result);
418
+ }
419
+
420
+ // Move a pending suggestion into shadow mode
421
+ if (url.pathname.startsWith('/suggestions/') && url.pathname.endsWith('/shadow') && request.method === 'POST') {
422
+ const id = url.pathname.split('/')[2];
423
+ if (!id) {
424
+ return Response.json({ error: 'Missing suggestion ID' }, { status: 400 });
425
+ }
426
+
427
+ const success = await enterShadowMode(env.PLATFORM_DB, id, log);
428
+ if (success) {
429
+ return Response.json({ success: true, action: 'entered-shadow' });
430
+ }
431
+ return Response.json({ error: 'Failed to enter shadow mode' }, { status: 400 });
432
+ }
433
+
434
+ // Evaluate static patterns for potential migration
435
+ if (url.pathname === '/evaluate-static' && request.method === 'GET') {
436
+ // Convert static regex patterns to input format
437
+ const staticPatterns: StaticPatternInput[] = TRANSIENT_ERROR_PATTERNS.map((p, i) => ({
438
+ pattern: p.pattern.source, // Get regex source string
439
+ category: p.category,
440
+ index: i + 1,
441
+ }));
442
+
443
+ // Allow limiting which patterns to evaluate (for testing)
444
+ const startParam = url.searchParams.get('start');
445
+ const endParam = url.searchParams.get('end');
446
+ const start = startParam ? parseInt(startParam, 10) - 1 : 0;
447
+ const end = endParam ? parseInt(endParam, 10) : staticPatterns.length;
448
+
449
+ const patternsToEvaluate = staticPatterns.slice(start, end);
450
+
451
+ log.info('Evaluating static patterns', {
452
+ total: staticPatterns.length,
453
+ evaluating: patternsToEvaluate.length,
454
+ range: `${start + 1}-${end}`,
455
+ });
456
+
457
+ const evaluation = await evaluateStaticPatterns(patternsToEvaluate, env, log);
458
+
459
+ if (!evaluation) {
460
+ return Response.json({ error: 'AI evaluation failed' }, { status: 500 });
461
+ }
462
+
463
+ // Add summary stats
464
+ const stats = {
465
+ totalPatterns: staticPatterns.length,
466
+ evaluated: patternsToEvaluate.length,
467
+ keepStatic: evaluation.evaluations.filter((e) => e.verdict === 'keep-static').length,
468
+ migrateDynamic: evaluation.evaluations.filter((e) => e.verdict === 'migrate-dynamic')
469
+ .length,
470
+ merge: evaluation.evaluations.filter((e) => e.verdict === 'merge').length,
471
+ deprecate: evaluation.evaluations.filter((e) => e.verdict === 'deprecate').length,
472
+ };
473
+
474
+ return Response.json({
475
+ ...evaluation,
476
+ stats,
477
+ patterns: patternsToEvaluate, // Include input patterns for reference
478
+ });
479
+ }
480
+
481
+ // API index
482
+ return Response.json({
483
+ service: 'pattern-discovery',
484
+ endpoints: [
485
+ 'GET /health - Health check',
486
+ 'GET /discover - Run pattern discovery',
487
+ 'GET /suggestions - List pending suggestions',
488
+ 'POST /suggestions/:id?action=approve&by=name - Approve suggestion',
489
+ 'POST /suggestions/:id?action=reject&by=name&reason=text - Reject suggestion',
490
+ 'POST /suggestions/:id/shadow - Move pending suggestion to shadow mode',
491
+ 'POST /cache/refresh - Refresh KV cache',
492
+ 'GET /patterns - List approved patterns with match stats',
493
+ 'GET /patterns/stats - Pattern statistics summary',
494
+ 'GET /evaluate-static?start=N&end=M - Evaluate static patterns with AI for migration',
495
+ 'GET /evaluate-shadow - Run shadow evaluation cycle manually',
496
+ ],
497
+ });
498
+ } catch (error) {
499
+ if (error instanceof CircuitBreakerError) {
500
+ log.warn('Circuit breaker tripped', error);
501
+ return Response.json(
502
+ { error: 'Service temporarily unavailable', code: 'CIRCUIT_BREAKER' },
503
+ { status: 503, headers: { 'Retry-After': '60' } }
504
+ );
505
+ }
506
+
507
+ log.error('Request failed', error);
508
+ return Response.json({ error: 'Internal server error' }, { status: 500 });
509
+ }
510
+ },
511
+ };
512
+
513
+ // =============================================================================
514
+ // DISCOVERY LOGIC
515
+ // =============================================================================
516
+
517
+ /**
518
+ * Run the full pattern discovery pipeline
519
+ */
520
+ async function runDiscovery(
521
+ env: Env,
522
+ log: ReturnType<typeof createLogger>
523
+ ): Promise<DiscoveryResult> {
524
+ const runId = `discovery-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
525
+ const errors: string[] = [];
526
+
527
+ log.info('Starting pattern discovery', { runId });
528
+
529
+ // Step 1: Query unclassified errors
530
+ const unclassifiedErrors = await queryUnclassifiedErrors(env.PLATFORM_DB, log);
531
+ if (unclassifiedErrors.length === 0) {
532
+ log.info('No unclassified errors to process');
533
+ return {
534
+ runId,
535
+ runAt: Math.floor(Date.now() / 1000),
536
+ clustersFound: 0,
537
+ clustersProcessed: 0,
538
+ suggestionsCreated: 0,
539
+ errors: [],
540
+ };
541
+ }
542
+
543
+ // Step 2: Cluster similar errors
544
+ const clusteredErrors = clusterErrors(unclassifiedErrors);
545
+ const clusters = buildClusterObjects(clusteredErrors);
546
+
547
+ log.info('Clustered errors', {
548
+ totalErrors: unclassifiedErrors.length,
549
+ uniqueClusters: clusteredErrors.size,
550
+ significantClusters: clusters.length,
551
+ });
552
+
553
+ // Step 3: Store clusters for tracking
554
+ await storeClusters(env.PLATFORM_DB, clusters, log);
555
+
556
+ // Step 4: Get sample messages for each cluster
557
+ const sampleMessages = new Map<string, string[]>();
558
+ for (const cluster of clusters) {
559
+ // Find the errors that belong to this cluster
560
+ const clusterHash = cluster.clusterHash;
561
+ const clusterErrorsList: { normalizedMessage: string }[] = [];
562
+
563
+ for (const [hash, errs] of clusteredErrors) {
564
+ // Simple hash comparison (our clustering is based on this hash)
565
+ const testHash = hashMessage(errs[0]?.normalizedMessage || '');
566
+ if (testHash === clusterHash || hash === clusterHash) {
567
+ clusterErrorsList.push(...errs.map((e) => ({ normalizedMessage: e.normalizedMessage })));
568
+ break;
569
+ }
570
+ }
571
+
572
+ const samples = getSampleMessages(
573
+ clusterErrorsList.map((e) => ({
574
+ fingerprint: '',
575
+ scriptName: '',
576
+ normalizedMessage: e.normalizedMessage,
577
+ occurrenceCount: 1,
578
+ lastSeenAt: 0,
579
+ })),
580
+ MAX_SAMPLES_PER_CLUSTER
581
+ );
582
+ sampleMessages.set(cluster.id, samples);
583
+ }
584
+
585
+ // Step 5: Call AI for pattern suggestions
586
+ let suggestionsCreated = 0;
587
+
588
+ if (clusters.length > 0 && env.PLATFORM_AI_GATEWAY_KEY) {
589
+ const aiResponse = await suggestPatterns(clusters, sampleMessages, env, log);
590
+
591
+ if (aiResponse && aiResponse.patterns.length > 0) {
592
+ // Step 6: Store suggestions and validate
593
+ for (let i = 0; i < Math.min(clusters.length, aiResponse.patterns.length); i++) {
594
+ const cluster = clusters[i];
595
+ const pattern = aiResponse.patterns[i];
596
+
597
+ if (!pattern || pattern.confidence < 0.5) {
598
+ await updateClusterStatus(env.PLATFORM_DB, cluster.id, 'ignored');
599
+ continue;
600
+ }
601
+
602
+ // Validate pattern safety
603
+ const safetyError = validatePatternSafety(pattern.patternType, pattern.patternValue);
604
+ if (safetyError) {
605
+ log.warn('Pattern failed safety check', {
606
+ cluster: cluster.id,
607
+ error: safetyError,
608
+ });
609
+ errors.push(`Cluster ${cluster.id}: ${safetyError}`);
610
+ await updateClusterStatus(env.PLATFORM_DB, cluster.id, 'ignored');
611
+ continue;
612
+ }
613
+
614
+ // Store the suggestion
615
+ const suggestionIds = await storePatternSuggestions(
616
+ env.PLATFORM_DB,
617
+ cluster,
618
+ { patterns: [pattern], summary: aiResponse.summary },
619
+ log
620
+ );
621
+
622
+ if (suggestionIds.length > 0) {
623
+ suggestionsCreated += suggestionIds.length;
624
+ await updateClusterStatus(env.PLATFORM_DB, cluster.id, 'suggested', suggestionIds[0]);
625
+ }
626
+ }
627
+ }
628
+ } else if (!env.PLATFORM_AI_GATEWAY_KEY) {
629
+ log.warn('AI Gateway key not configured, skipping AI analysis');
630
+ errors.push('AI Gateway key not configured');
631
+ }
632
+
633
+ return {
634
+ runId,
635
+ runAt: Math.floor(Date.now() / 1000),
636
+ clustersFound: clusters.length,
637
+ clustersProcessed: clusters.length,
638
+ suggestionsCreated,
639
+ errors,
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Simple hash function (duplicated from clustering.ts for local use)
645
+ */
646
+ function hashMessage(message: string): string {
647
+ const normalized = message
648
+ .toLowerCase()
649
+ .replace(/\s+/g, ' ')
650
+ .replace(/\d+/g, 'N')
651
+ .replace(/[a-f0-9]{8,}/gi, 'HASH')
652
+ .trim();
653
+
654
+ let hash = 0;
655
+ for (let i = 0; i < normalized.length; i++) {
656
+ const char = normalized.charCodeAt(i);
657
+ hash = (hash << 5) - hash + char;
658
+ hash = hash & hash;
659
+ }
660
+ return hash.toString(16);
661
+ }