@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,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Operations for Pattern Discovery
|
|
3
|
+
*
|
|
4
|
+
* D1 and KV operations for pattern suggestions and audit logs.
|
|
5
|
+
*
|
|
6
|
+
* @module workers/lib/pattern-discovery/storage
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { D1Database, KVNamespace } from '@cloudflare/workers-types';
|
|
10
|
+
import type {
|
|
11
|
+
PatternSuggestion,
|
|
12
|
+
PatternRule,
|
|
13
|
+
AISuggestionResponse,
|
|
14
|
+
AuditAction,
|
|
15
|
+
ErrorCluster,
|
|
16
|
+
} from './types';
|
|
17
|
+
import type { Logger } from '@littlebearapps/platform-sdk';
|
|
18
|
+
|
|
19
|
+
/** KV key prefix for approved dynamic patterns */
|
|
20
|
+
export const DYNAMIC_PATTERNS_KEY = 'PATTERNS:DYNAMIC:APPROVED';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a unique ID
|
|
24
|
+
*/
|
|
25
|
+
function generateId(prefix: string): string {
|
|
26
|
+
const timestamp = Date.now().toString(36);
|
|
27
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
28
|
+
return `${prefix}-${timestamp}-${random}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Store pattern suggestions from AI response
|
|
33
|
+
*/
|
|
34
|
+
export async function storePatternSuggestions(
|
|
35
|
+
db: D1Database,
|
|
36
|
+
cluster: ErrorCluster,
|
|
37
|
+
aiResponse: AISuggestionResponse,
|
|
38
|
+
log: Logger
|
|
39
|
+
): Promise<string[]> {
|
|
40
|
+
const suggestionIds: string[] = [];
|
|
41
|
+
|
|
42
|
+
for (const pattern of aiResponse.patterns) {
|
|
43
|
+
// Skip low-confidence suggestions
|
|
44
|
+
if (pattern.confidence < 0.5) {
|
|
45
|
+
log.debug('Skipping low-confidence pattern', {
|
|
46
|
+
category: pattern.category,
|
|
47
|
+
confidence: pattern.confidence,
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Skip if an active pattern with the same type+value already exists.
|
|
53
|
+
// Prevents duplicate suggestions when the same error cluster appears in
|
|
54
|
+
// consecutive cron runs (e.g. duplicate "Slow workflow step" shadow suggestions).
|
|
55
|
+
try {
|
|
56
|
+
const existing = await db
|
|
57
|
+
.prepare(
|
|
58
|
+
`SELECT id, status FROM transient_pattern_suggestions
|
|
59
|
+
WHERE pattern_type = ? AND pattern_value = ?
|
|
60
|
+
AND status IN ('approved', 'shadow', 'pending')
|
|
61
|
+
LIMIT 1`
|
|
62
|
+
)
|
|
63
|
+
.bind(pattern.patternType, pattern.patternValue)
|
|
64
|
+
.first<{ id: string; status: string }>();
|
|
65
|
+
|
|
66
|
+
if (existing) {
|
|
67
|
+
log.info('Skipping duplicate pattern suggestion', {
|
|
68
|
+
existingId: existing.id,
|
|
69
|
+
existingStatus: existing.status,
|
|
70
|
+
patternValue: pattern.patternValue,
|
|
71
|
+
category: pattern.category,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// Non-blocking — proceed with insertion if dedup check fails
|
|
77
|
+
log.warn('Dedup check failed, proceeding with insertion', { error });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const id = generateId('suggestion');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await db
|
|
84
|
+
.prepare(
|
|
85
|
+
`
|
|
86
|
+
INSERT INTO transient_pattern_suggestions (
|
|
87
|
+
id, pattern_type, pattern_value, category, scope,
|
|
88
|
+
confidence_score, sample_messages, ai_reasoning, cluster_id,
|
|
89
|
+
status
|
|
90
|
+
) VALUES (?, ?, ?, ?, 'global', ?, ?, ?, ?, 'pending')
|
|
91
|
+
`
|
|
92
|
+
)
|
|
93
|
+
.bind(
|
|
94
|
+
id,
|
|
95
|
+
pattern.patternType,
|
|
96
|
+
pattern.patternValue,
|
|
97
|
+
pattern.category,
|
|
98
|
+
pattern.confidence,
|
|
99
|
+
JSON.stringify(pattern.positiveExamples),
|
|
100
|
+
pattern.reasoning,
|
|
101
|
+
cluster.id
|
|
102
|
+
)
|
|
103
|
+
.run();
|
|
104
|
+
|
|
105
|
+
// Log audit event
|
|
106
|
+
await logAuditEvent(db, id, 'created', 'ai:deepseek', 'Pattern suggested by AI analysis', {
|
|
107
|
+
confidence: pattern.confidence,
|
|
108
|
+
clusterId: cluster.id,
|
|
109
|
+
clusterOccurrences: cluster.occurrenceCount,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
suggestionIds.push(id);
|
|
113
|
+
|
|
114
|
+
log.info('Stored pattern suggestion', {
|
|
115
|
+
id,
|
|
116
|
+
patternType: pattern.patternType,
|
|
117
|
+
category: pattern.category,
|
|
118
|
+
confidence: pattern.confidence,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
log.error('Failed to store pattern suggestion', error, {
|
|
122
|
+
category: pattern.category,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return suggestionIds;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Log an audit event
|
|
132
|
+
*/
|
|
133
|
+
export async function logAuditEvent(
|
|
134
|
+
db: D1Database,
|
|
135
|
+
patternId: string,
|
|
136
|
+
action: AuditAction,
|
|
137
|
+
actor: string,
|
|
138
|
+
reason: string,
|
|
139
|
+
metadata?: Record<string, unknown>
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const id = generateId('audit');
|
|
142
|
+
|
|
143
|
+
await db
|
|
144
|
+
.prepare(
|
|
145
|
+
`
|
|
146
|
+
INSERT INTO pattern_audit_log (id, pattern_id, action, actor, reason, metadata)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
148
|
+
`
|
|
149
|
+
)
|
|
150
|
+
.bind(id, patternId, action, actor, reason, metadata ? JSON.stringify(metadata) : null)
|
|
151
|
+
.run();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get pending suggestions for review
|
|
156
|
+
*/
|
|
157
|
+
export async function getPendingSuggestions(
|
|
158
|
+
db: D1Database,
|
|
159
|
+
limit: number = 20
|
|
160
|
+
): Promise<PatternSuggestion[]> {
|
|
161
|
+
const result = await db
|
|
162
|
+
.prepare(
|
|
163
|
+
`
|
|
164
|
+
SELECT
|
|
165
|
+
id, pattern_type as patternType, pattern_value as patternValue,
|
|
166
|
+
category, scope, confidence_score as confidenceScore,
|
|
167
|
+
sample_messages as sampleMessages, ai_reasoning as aiReasoning,
|
|
168
|
+
cluster_id as clusterId, status,
|
|
169
|
+
reviewed_by as reviewedBy, reviewed_at as reviewedAt,
|
|
170
|
+
rejection_reason as rejectionReason,
|
|
171
|
+
backtest_match_count as backtestMatchCount,
|
|
172
|
+
backtest_total_errors as backtestTotalErrors,
|
|
173
|
+
backtest_match_rate as backtestMatchRate,
|
|
174
|
+
shadow_mode_start as shadowModeStart,
|
|
175
|
+
shadow_mode_end as shadowModeEnd,
|
|
176
|
+
shadow_mode_matches as shadowModeMatches,
|
|
177
|
+
shadow_match_days as shadowMatchDays,
|
|
178
|
+
enabled_at as enabledAt, disabled_at as disabledAt,
|
|
179
|
+
last_matched_at as lastMatchedAt, match_count as matchCount,
|
|
180
|
+
is_protected as isProtected, source, original_regex as originalRegex,
|
|
181
|
+
created_at as createdAt, updated_at as updatedAt
|
|
182
|
+
FROM transient_pattern_suggestions
|
|
183
|
+
WHERE status = 'pending'
|
|
184
|
+
ORDER BY confidence_score DESC, created_at ASC
|
|
185
|
+
LIMIT ?
|
|
186
|
+
`
|
|
187
|
+
)
|
|
188
|
+
.bind(limit)
|
|
189
|
+
.all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
|
|
190
|
+
|
|
191
|
+
return result.results.map((r) => ({
|
|
192
|
+
...r,
|
|
193
|
+
sampleMessages: JSON.parse(r.sampleMessages || '[]'),
|
|
194
|
+
shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
|
|
195
|
+
isProtected: Boolean(r.isProtected),
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Approve a pattern suggestion
|
|
201
|
+
*/
|
|
202
|
+
export async function approveSuggestion(
|
|
203
|
+
db: D1Database,
|
|
204
|
+
kv: KVNamespace,
|
|
205
|
+
suggestionId: string,
|
|
206
|
+
reviewedBy: string,
|
|
207
|
+
log: Logger
|
|
208
|
+
): Promise<boolean> {
|
|
209
|
+
const now = Math.floor(Date.now() / 1000);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
// Update suggestion status
|
|
213
|
+
await db
|
|
214
|
+
.prepare(
|
|
215
|
+
`
|
|
216
|
+
UPDATE transient_pattern_suggestions
|
|
217
|
+
SET status = 'approved', reviewed_by = ?, reviewed_at = ?,
|
|
218
|
+
enabled_at = ?, updated_at = unixepoch()
|
|
219
|
+
WHERE id = ? AND status = 'pending'
|
|
220
|
+
`
|
|
221
|
+
)
|
|
222
|
+
.bind(reviewedBy, now, now, suggestionId)
|
|
223
|
+
.run();
|
|
224
|
+
|
|
225
|
+
// Log audit event
|
|
226
|
+
await logAuditEvent(db, suggestionId, 'approved', `human:${reviewedBy}`, 'Pattern approved');
|
|
227
|
+
|
|
228
|
+
// Refresh KV cache
|
|
229
|
+
await refreshDynamicPatternsCache(db, kv, log);
|
|
230
|
+
|
|
231
|
+
log.info('Approved pattern suggestion', { suggestionId, reviewedBy });
|
|
232
|
+
return true;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
log.error('Failed to approve suggestion', error, { suggestionId });
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reject a pattern suggestion
|
|
241
|
+
*/
|
|
242
|
+
export async function rejectSuggestion(
|
|
243
|
+
db: D1Database,
|
|
244
|
+
suggestionId: string,
|
|
245
|
+
reviewedBy: string,
|
|
246
|
+
reason: string,
|
|
247
|
+
log: Logger
|
|
248
|
+
): Promise<boolean> {
|
|
249
|
+
const now = Math.floor(Date.now() / 1000);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await db
|
|
253
|
+
.prepare(
|
|
254
|
+
`
|
|
255
|
+
UPDATE transient_pattern_suggestions
|
|
256
|
+
SET status = 'rejected', reviewed_by = ?, reviewed_at = ?,
|
|
257
|
+
rejection_reason = ?, updated_at = unixepoch()
|
|
258
|
+
WHERE id = ? AND status = 'pending'
|
|
259
|
+
`
|
|
260
|
+
)
|
|
261
|
+
.bind(reviewedBy, now, reason, suggestionId)
|
|
262
|
+
.run();
|
|
263
|
+
|
|
264
|
+
await logAuditEvent(db, suggestionId, 'rejected', `human:${reviewedBy}`, reason);
|
|
265
|
+
|
|
266
|
+
log.info('Rejected pattern suggestion', { suggestionId, reviewedBy, reason });
|
|
267
|
+
return true;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
log.error('Failed to reject suggestion', error, { suggestionId });
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Disable an approved pattern
|
|
276
|
+
*/
|
|
277
|
+
export async function disableSuggestion(
|
|
278
|
+
db: D1Database,
|
|
279
|
+
kv: KVNamespace,
|
|
280
|
+
suggestionId: string,
|
|
281
|
+
actor: string,
|
|
282
|
+
reason: string,
|
|
283
|
+
isAutomatic: boolean,
|
|
284
|
+
log: Logger
|
|
285
|
+
): Promise<boolean> {
|
|
286
|
+
const now = Math.floor(Date.now() / 1000);
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await db
|
|
290
|
+
.prepare(
|
|
291
|
+
`
|
|
292
|
+
UPDATE transient_pattern_suggestions
|
|
293
|
+
SET status = 'disabled', disabled_at = ?, updated_at = unixepoch()
|
|
294
|
+
WHERE id = ? AND status = 'approved'
|
|
295
|
+
`
|
|
296
|
+
)
|
|
297
|
+
.bind(now, suggestionId)
|
|
298
|
+
.run();
|
|
299
|
+
|
|
300
|
+
const action: AuditAction = isAutomatic ? 'auto-disabled' : 'disabled';
|
|
301
|
+
await logAuditEvent(db, suggestionId, action, actor, reason);
|
|
302
|
+
|
|
303
|
+
// Refresh KV cache
|
|
304
|
+
await refreshDynamicPatternsCache(db, kv, log);
|
|
305
|
+
|
|
306
|
+
log.info('Disabled pattern', { suggestionId, actor, reason, isAutomatic });
|
|
307
|
+
return true;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
log.error('Failed to disable pattern', error, { suggestionId });
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Refresh the KV cache of approved dynamic patterns
|
|
316
|
+
*/
|
|
317
|
+
export async function refreshDynamicPatternsCache(
|
|
318
|
+
db: D1Database,
|
|
319
|
+
kv: KVNamespace,
|
|
320
|
+
log: Logger
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
try {
|
|
323
|
+
const result = await db
|
|
324
|
+
.prepare(
|
|
325
|
+
`
|
|
326
|
+
SELECT
|
|
327
|
+
id, pattern_type as type, pattern_value as value,
|
|
328
|
+
category, scope
|
|
329
|
+
FROM transient_pattern_suggestions
|
|
330
|
+
WHERE status = 'approved'
|
|
331
|
+
ORDER BY created_at ASC
|
|
332
|
+
`
|
|
333
|
+
)
|
|
334
|
+
.all<PatternRule & { id: string }>();
|
|
335
|
+
|
|
336
|
+
const patterns = result.results;
|
|
337
|
+
|
|
338
|
+
// 7-day TTL as safety net — if refreshDynamicPatternsCache() fails
|
|
339
|
+
// silently, stale patterns will auto-expire rather than persist forever.
|
|
340
|
+
// Daily crons refresh this well within the 7-day window.
|
|
341
|
+
// (Previously had no TTL; before that, 1h TTL caused vanishing between cron runs.)
|
|
342
|
+
await kv.put(DYNAMIC_PATTERNS_KEY, JSON.stringify(patterns), { expirationTtl: 604800 });
|
|
343
|
+
|
|
344
|
+
log.info('Refreshed dynamic patterns cache', { count: patterns.length });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
log.error('Failed to refresh patterns cache', error);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get approved dynamic patterns from KV (with D1 fallback)
|
|
352
|
+
*/
|
|
353
|
+
export async function getDynamicPatterns(
|
|
354
|
+
kv: KVNamespace,
|
|
355
|
+
db: D1Database,
|
|
356
|
+
log: Logger
|
|
357
|
+
): Promise<PatternRule[]> {
|
|
358
|
+
try {
|
|
359
|
+
// Try KV first
|
|
360
|
+
const cached = await kv.get(DYNAMIC_PATTERNS_KEY);
|
|
361
|
+
if (cached) {
|
|
362
|
+
const patterns = JSON.parse(cached) as PatternRule[];
|
|
363
|
+
log.debug('Loaded dynamic patterns from KV', { count: patterns.length });
|
|
364
|
+
return patterns;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Fallback to D1
|
|
368
|
+
const result = await db
|
|
369
|
+
.prepare(
|
|
370
|
+
`
|
|
371
|
+
SELECT
|
|
372
|
+
pattern_type as type, pattern_value as value,
|
|
373
|
+
category, scope
|
|
374
|
+
FROM transient_pattern_suggestions
|
|
375
|
+
WHERE status = 'approved'
|
|
376
|
+
ORDER BY created_at ASC
|
|
377
|
+
`
|
|
378
|
+
)
|
|
379
|
+
.all<PatternRule>();
|
|
380
|
+
|
|
381
|
+
const patterns = result.results;
|
|
382
|
+
|
|
383
|
+
// Cache in KV for next time (7-day safety TTL, refreshed daily by cron)
|
|
384
|
+
await kv.put(DYNAMIC_PATTERNS_KEY, JSON.stringify(patterns), { expirationTtl: 604800 });
|
|
385
|
+
|
|
386
|
+
log.debug('Loaded dynamic patterns from D1', { count: patterns.length });
|
|
387
|
+
return patterns;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
log.error('Failed to get dynamic patterns', error);
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Update match statistics for a pattern
|
|
396
|
+
*/
|
|
397
|
+
export async function recordPatternMatch(
|
|
398
|
+
db: D1Database,
|
|
399
|
+
patternId: string
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
await db
|
|
402
|
+
.prepare(
|
|
403
|
+
`
|
|
404
|
+
UPDATE transient_pattern_suggestions
|
|
405
|
+
SET match_count = match_count + 1,
|
|
406
|
+
last_matched_at = unixepoch(),
|
|
407
|
+
updated_at = unixepoch()
|
|
408
|
+
WHERE id = ?
|
|
409
|
+
`
|
|
410
|
+
)
|
|
411
|
+
.bind(patternId)
|
|
412
|
+
.run();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Evidence for a pattern match - used for human review context
|
|
417
|
+
*/
|
|
418
|
+
export interface PatternMatchEvidence {
|
|
419
|
+
patternId: string;
|
|
420
|
+
scriptName: string;
|
|
421
|
+
project?: string;
|
|
422
|
+
errorFingerprint?: string;
|
|
423
|
+
normalizedMessage?: string;
|
|
424
|
+
errorType?: string;
|
|
425
|
+
priority?: string;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Record detailed match evidence for a pattern
|
|
430
|
+
* Used by error-collector when dynamic patterns match
|
|
431
|
+
*/
|
|
432
|
+
export async function recordPatternMatchEvidence(
|
|
433
|
+
db: D1Database,
|
|
434
|
+
evidence: PatternMatchEvidence
|
|
435
|
+
): Promise<void> {
|
|
436
|
+
const id = generateId('evidence');
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
await db
|
|
440
|
+
.prepare(
|
|
441
|
+
`
|
|
442
|
+
INSERT INTO pattern_match_evidence
|
|
443
|
+
(id, pattern_id, script_name, project, error_fingerprint, normalized_message, error_type, priority)
|
|
444
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
445
|
+
`
|
|
446
|
+
)
|
|
447
|
+
.bind(
|
|
448
|
+
id,
|
|
449
|
+
evidence.patternId,
|
|
450
|
+
evidence.scriptName,
|
|
451
|
+
evidence.project ?? null,
|
|
452
|
+
evidence.errorFingerprint ?? null,
|
|
453
|
+
evidence.normalizedMessage ?? null,
|
|
454
|
+
evidence.errorType ?? null,
|
|
455
|
+
evidence.priority ?? null
|
|
456
|
+
)
|
|
457
|
+
.run();
|
|
458
|
+
|
|
459
|
+
// Also increment the simple match_count on the pattern
|
|
460
|
+
await recordPatternMatch(db, evidence.patternId);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
// Log but don't throw - evidence tracking is non-critical
|
|
463
|
+
console.error('Failed to record pattern match evidence:', error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Aggregated evidence for pattern review
|
|
469
|
+
*/
|
|
470
|
+
export interface AggregatedPatternEvidence {
|
|
471
|
+
totalMatches: number;
|
|
472
|
+
matchesByProject: Record<string, number>;
|
|
473
|
+
matchesByScript: Record<string, number>;
|
|
474
|
+
sampleMessages: string[];
|
|
475
|
+
distinctDays: number;
|
|
476
|
+
firstMatchAt: number | null;
|
|
477
|
+
lastMatchAt: number | null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Aggregate match evidence for a pattern
|
|
482
|
+
* Used by shadow evaluation to build review context
|
|
483
|
+
*/
|
|
484
|
+
export async function aggregatePatternEvidence(
|
|
485
|
+
db: D1Database,
|
|
486
|
+
patternId: string
|
|
487
|
+
): Promise<AggregatedPatternEvidence> {
|
|
488
|
+
// Get total matches
|
|
489
|
+
const totalResult = await db
|
|
490
|
+
.prepare(`SELECT COUNT(*) as count FROM pattern_match_evidence WHERE pattern_id = ?`)
|
|
491
|
+
.bind(patternId)
|
|
492
|
+
.first<{ count: number }>();
|
|
493
|
+
|
|
494
|
+
// Get matches grouped by project
|
|
495
|
+
const projectsResult = await db
|
|
496
|
+
.prepare(
|
|
497
|
+
`
|
|
498
|
+
SELECT COALESCE(project, 'unknown') as project, COUNT(*) as count
|
|
499
|
+
FROM pattern_match_evidence
|
|
500
|
+
WHERE pattern_id = ?
|
|
501
|
+
GROUP BY project
|
|
502
|
+
ORDER BY count DESC
|
|
503
|
+
`
|
|
504
|
+
)
|
|
505
|
+
.bind(patternId)
|
|
506
|
+
.all<{ project: string; count: number }>();
|
|
507
|
+
|
|
508
|
+
// Get matches grouped by script
|
|
509
|
+
const scriptsResult = await db
|
|
510
|
+
.prepare(
|
|
511
|
+
`
|
|
512
|
+
SELECT script_name, COUNT(*) as count
|
|
513
|
+
FROM pattern_match_evidence
|
|
514
|
+
WHERE pattern_id = ?
|
|
515
|
+
GROUP BY script_name
|
|
516
|
+
ORDER BY count DESC
|
|
517
|
+
LIMIT 10
|
|
518
|
+
`
|
|
519
|
+
)
|
|
520
|
+
.bind(patternId)
|
|
521
|
+
.all<{ script_name: string; count: number }>();
|
|
522
|
+
|
|
523
|
+
// Get distinct days (count unique dates)
|
|
524
|
+
const daysResult = await db
|
|
525
|
+
.prepare(
|
|
526
|
+
`
|
|
527
|
+
SELECT COUNT(DISTINCT date(matched_at, 'unixepoch')) as days
|
|
528
|
+
FROM pattern_match_evidence
|
|
529
|
+
WHERE pattern_id = ?
|
|
530
|
+
`
|
|
531
|
+
)
|
|
532
|
+
.bind(patternId)
|
|
533
|
+
.first<{ days: number }>();
|
|
534
|
+
|
|
535
|
+
// Get sample messages (distinct, up to 5)
|
|
536
|
+
const messagesResult = await db
|
|
537
|
+
.prepare(
|
|
538
|
+
`
|
|
539
|
+
SELECT DISTINCT normalized_message
|
|
540
|
+
FROM pattern_match_evidence
|
|
541
|
+
WHERE pattern_id = ? AND normalized_message IS NOT NULL
|
|
542
|
+
LIMIT 5
|
|
543
|
+
`
|
|
544
|
+
)
|
|
545
|
+
.bind(patternId)
|
|
546
|
+
.all<{ normalized_message: string }>();
|
|
547
|
+
|
|
548
|
+
// Get first and last match times
|
|
549
|
+
const timesResult = await db
|
|
550
|
+
.prepare(
|
|
551
|
+
`
|
|
552
|
+
SELECT MIN(matched_at) as first_match, MAX(matched_at) as last_match
|
|
553
|
+
FROM pattern_match_evidence
|
|
554
|
+
WHERE pattern_id = ?
|
|
555
|
+
`
|
|
556
|
+
)
|
|
557
|
+
.bind(patternId)
|
|
558
|
+
.first<{ first_match: number | null; last_match: number | null }>();
|
|
559
|
+
|
|
560
|
+
// Build result
|
|
561
|
+
const matchesByProject: Record<string, number> = {};
|
|
562
|
+
for (const row of projectsResult.results) {
|
|
563
|
+
matchesByProject[row.project] = row.count;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const matchesByScript: Record<string, number> = {};
|
|
567
|
+
for (const row of scriptsResult.results) {
|
|
568
|
+
matchesByScript[row.script_name] = row.count;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
totalMatches: totalResult?.count ?? 0,
|
|
573
|
+
matchesByProject,
|
|
574
|
+
matchesByScript,
|
|
575
|
+
sampleMessages: messagesResult.results.map((r) => r.normalized_message),
|
|
576
|
+
distinctDays: daysResult?.days ?? 0,
|
|
577
|
+
firstMatchAt: timesResult?.first_match ?? null,
|
|
578
|
+
lastMatchAt: timesResult?.last_match ?? null,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Get approved patterns with match statistics
|
|
584
|
+
*/
|
|
585
|
+
export async function getApprovedPatterns(
|
|
586
|
+
db: D1Database,
|
|
587
|
+
limit: number = 50,
|
|
588
|
+
offset: number = 0
|
|
589
|
+
): Promise<PatternSuggestion[]> {
|
|
590
|
+
const result = await db
|
|
591
|
+
.prepare(
|
|
592
|
+
`
|
|
593
|
+
SELECT
|
|
594
|
+
id, pattern_type as patternType, pattern_value as patternValue,
|
|
595
|
+
category, scope, confidence_score as confidenceScore,
|
|
596
|
+
sample_messages as sampleMessages, ai_reasoning as aiReasoning,
|
|
597
|
+
cluster_id as clusterId, status,
|
|
598
|
+
reviewed_by as reviewedBy, reviewed_at as reviewedAt,
|
|
599
|
+
rejection_reason as rejectionReason,
|
|
600
|
+
backtest_match_count as backtestMatchCount,
|
|
601
|
+
backtest_total_errors as backtestTotalErrors,
|
|
602
|
+
backtest_match_rate as backtestMatchRate,
|
|
603
|
+
shadow_mode_start as shadowModeStart,
|
|
604
|
+
shadow_mode_end as shadowModeEnd,
|
|
605
|
+
shadow_mode_matches as shadowModeMatches,
|
|
606
|
+
shadow_match_days as shadowMatchDays,
|
|
607
|
+
enabled_at as enabledAt, disabled_at as disabledAt,
|
|
608
|
+
last_matched_at as lastMatchedAt, match_count as matchCount,
|
|
609
|
+
is_protected as isProtected, source, original_regex as originalRegex,
|
|
610
|
+
created_at as createdAt, updated_at as updatedAt
|
|
611
|
+
FROM transient_pattern_suggestions
|
|
612
|
+
WHERE status = 'approved'
|
|
613
|
+
ORDER BY match_count DESC, created_at DESC
|
|
614
|
+
LIMIT ? OFFSET ?
|
|
615
|
+
`
|
|
616
|
+
)
|
|
617
|
+
.bind(limit, offset)
|
|
618
|
+
.all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
|
|
619
|
+
|
|
620
|
+
return result.results.map((r) => ({
|
|
621
|
+
...r,
|
|
622
|
+
sampleMessages: JSON.parse(r.sampleMessages || '[]'),
|
|
623
|
+
shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
|
|
624
|
+
isProtected: Boolean(r.isProtected),
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get patterns in shadow mode
|
|
630
|
+
*/
|
|
631
|
+
export async function getShadowPatterns(
|
|
632
|
+
db: D1Database,
|
|
633
|
+
limit: number = 50
|
|
634
|
+
): Promise<PatternSuggestion[]> {
|
|
635
|
+
const result = await db
|
|
636
|
+
.prepare(
|
|
637
|
+
`
|
|
638
|
+
SELECT
|
|
639
|
+
id, pattern_type as patternType, pattern_value as patternValue,
|
|
640
|
+
category, scope, confidence_score as confidenceScore,
|
|
641
|
+
sample_messages as sampleMessages, ai_reasoning as aiReasoning,
|
|
642
|
+
cluster_id as clusterId, status,
|
|
643
|
+
reviewed_by as reviewedBy, reviewed_at as reviewedAt,
|
|
644
|
+
rejection_reason as rejectionReason,
|
|
645
|
+
backtest_match_count as backtestMatchCount,
|
|
646
|
+
backtest_total_errors as backtestTotalErrors,
|
|
647
|
+
backtest_match_rate as backtestMatchRate,
|
|
648
|
+
shadow_mode_start as shadowModeStart,
|
|
649
|
+
shadow_mode_end as shadowModeEnd,
|
|
650
|
+
shadow_mode_matches as shadowModeMatches,
|
|
651
|
+
shadow_match_days as shadowMatchDays,
|
|
652
|
+
enabled_at as enabledAt, disabled_at as disabledAt,
|
|
653
|
+
last_matched_at as lastMatchedAt, match_count as matchCount,
|
|
654
|
+
is_protected as isProtected, source, original_regex as originalRegex,
|
|
655
|
+
created_at as createdAt, updated_at as updatedAt
|
|
656
|
+
FROM transient_pattern_suggestions
|
|
657
|
+
WHERE status = 'shadow'
|
|
658
|
+
ORDER BY shadow_mode_end ASC, created_at ASC
|
|
659
|
+
LIMIT ?
|
|
660
|
+
`
|
|
661
|
+
)
|
|
662
|
+
.bind(limit)
|
|
663
|
+
.all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
|
|
664
|
+
|
|
665
|
+
return result.results.map((r) => ({
|
|
666
|
+
...r,
|
|
667
|
+
sampleMessages: JSON.parse(r.sampleMessages || '[]'),
|
|
668
|
+
shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
|
|
669
|
+
isProtected: Boolean(r.isProtected),
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Get stale patterns
|
|
675
|
+
*/
|
|
676
|
+
export async function getStalePatterns(
|
|
677
|
+
db: D1Database,
|
|
678
|
+
limit: number = 50
|
|
679
|
+
): Promise<PatternSuggestion[]> {
|
|
680
|
+
const result = await db
|
|
681
|
+
.prepare(
|
|
682
|
+
`
|
|
683
|
+
SELECT
|
|
684
|
+
id, pattern_type as patternType, pattern_value as patternValue,
|
|
685
|
+
category, scope, confidence_score as confidenceScore,
|
|
686
|
+
sample_messages as sampleMessages, ai_reasoning as aiReasoning,
|
|
687
|
+
cluster_id as clusterId, status,
|
|
688
|
+
reviewed_by as reviewedBy, reviewed_at as reviewedAt,
|
|
689
|
+
rejection_reason as rejectionReason,
|
|
690
|
+
backtest_match_count as backtestMatchCount,
|
|
691
|
+
backtest_total_errors as backtestTotalErrors,
|
|
692
|
+
backtest_match_rate as backtestMatchRate,
|
|
693
|
+
shadow_mode_start as shadowModeStart,
|
|
694
|
+
shadow_mode_end as shadowModeEnd,
|
|
695
|
+
shadow_mode_matches as shadowModeMatches,
|
|
696
|
+
shadow_match_days as shadowMatchDays,
|
|
697
|
+
enabled_at as enabledAt, disabled_at as disabledAt,
|
|
698
|
+
last_matched_at as lastMatchedAt, match_count as matchCount,
|
|
699
|
+
is_protected as isProtected, source, original_regex as originalRegex,
|
|
700
|
+
created_at as createdAt, updated_at as updatedAt
|
|
701
|
+
FROM transient_pattern_suggestions
|
|
702
|
+
WHERE status = 'stale'
|
|
703
|
+
ORDER BY disabled_at DESC, created_at DESC
|
|
704
|
+
LIMIT ?
|
|
705
|
+
`
|
|
706
|
+
)
|
|
707
|
+
.bind(limit)
|
|
708
|
+
.all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
|
|
709
|
+
|
|
710
|
+
return result.results.map((r) => ({
|
|
711
|
+
...r,
|
|
712
|
+
sampleMessages: JSON.parse(r.sampleMessages || '[]'),
|
|
713
|
+
shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
|
|
714
|
+
isProtected: Boolean(r.isProtected),
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Get pattern statistics summary
|
|
720
|
+
*/
|
|
721
|
+
export async function getPatternStats(db: D1Database): Promise<{
|
|
722
|
+
pendingCount: number;
|
|
723
|
+
shadowCount: number;
|
|
724
|
+
approvedCount: number;
|
|
725
|
+
staleCount: number;
|
|
726
|
+
rejectedCount: number;
|
|
727
|
+
disabledCount: number;
|
|
728
|
+
protectedCount: number;
|
|
729
|
+
staticCount: number;
|
|
730
|
+
totalMatches: number;
|
|
731
|
+
lastDiscoveryRun: number | null;
|
|
732
|
+
activeCategories: string[];
|
|
733
|
+
}> {
|
|
734
|
+
// Get counts by status
|
|
735
|
+
const countsResult = await db
|
|
736
|
+
.prepare(
|
|
737
|
+
`
|
|
738
|
+
SELECT
|
|
739
|
+
status,
|
|
740
|
+
COUNT(*) as count,
|
|
741
|
+
SUM(match_count) as totalMatches
|
|
742
|
+
FROM transient_pattern_suggestions
|
|
743
|
+
GROUP BY status
|
|
744
|
+
`
|
|
745
|
+
)
|
|
746
|
+
.all<{ status: string; count: number; totalMatches: number | null }>();
|
|
747
|
+
|
|
748
|
+
const counts = countsResult.results.reduce(
|
|
749
|
+
(acc, row) => {
|
|
750
|
+
acc[row.status] = row.count;
|
|
751
|
+
if (row.status === 'approved') {
|
|
752
|
+
acc.totalMatches = row.totalMatches || 0;
|
|
753
|
+
}
|
|
754
|
+
return acc;
|
|
755
|
+
},
|
|
756
|
+
{ pending: 0, shadow: 0, approved: 0, stale: 0, rejected: 0, disabled: 0, totalMatches: 0 } as Record<string, number>
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
// Get protected and static counts
|
|
760
|
+
const protectedResult = await db
|
|
761
|
+
.prepare(
|
|
762
|
+
`
|
|
763
|
+
SELECT
|
|
764
|
+
SUM(CASE WHEN is_protected = 1 THEN 1 ELSE 0 END) as protectedCount,
|
|
765
|
+
SUM(CASE WHEN source = 'static-import' THEN 1 ELSE 0 END) as staticCount
|
|
766
|
+
FROM transient_pattern_suggestions
|
|
767
|
+
`
|
|
768
|
+
)
|
|
769
|
+
.first<{ protectedCount: number; staticCount: number }>();
|
|
770
|
+
|
|
771
|
+
// Get unique active categories
|
|
772
|
+
const categoriesResult = await db
|
|
773
|
+
.prepare(
|
|
774
|
+
`
|
|
775
|
+
SELECT DISTINCT category
|
|
776
|
+
FROM transient_pattern_suggestions
|
|
777
|
+
WHERE status = 'approved'
|
|
778
|
+
ORDER BY category
|
|
779
|
+
`
|
|
780
|
+
)
|
|
781
|
+
.all<{ category: string }>();
|
|
782
|
+
|
|
783
|
+
// Get last discovery run from clusters table
|
|
784
|
+
const lastRunResult = await db
|
|
785
|
+
.prepare(
|
|
786
|
+
`
|
|
787
|
+
SELECT MAX(created_at) as lastRun
|
|
788
|
+
FROM error_clusters
|
|
789
|
+
`
|
|
790
|
+
)
|
|
791
|
+
.first<{ lastRun: number | null }>();
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
pendingCount: counts.pending || 0,
|
|
795
|
+
shadowCount: counts.shadow || 0,
|
|
796
|
+
approvedCount: counts.approved || 0,
|
|
797
|
+
staleCount: counts.stale || 0,
|
|
798
|
+
rejectedCount: counts.rejected || 0,
|
|
799
|
+
disabledCount: counts.disabled || 0,
|
|
800
|
+
protectedCount: protectedResult?.protectedCount || 0,
|
|
801
|
+
staticCount: protectedResult?.staticCount || 0,
|
|
802
|
+
totalMatches: counts.totalMatches || 0,
|
|
803
|
+
lastDiscoveryRun: lastRunResult?.lastRun || null,
|
|
804
|
+
activeCategories: categoriesResult.results.map((r) => r.category),
|
|
805
|
+
};
|
|
806
|
+
}
|