@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.
- package/README.md +98 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +36 -6
- package/dist/prompts.d.ts +14 -2
- package/dist/prompts.js +29 -7
- package/dist/templates.js +78 -0
- package/package.json +3 -2
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- 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
|
+
}
|