@littlebearapps/platform-admin-sdk 1.4.0 → 1.4.2
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/dist/templates.d.ts +1 -1
- package/dist/templates.js +1 -1
- package/package.json +1 -1
- package/templates/standard/workers/error-collector.ts +106 -57
- package/templates/standard/workers/lib/error-collector/capture.ts +52 -2
- package/templates/standard/workers/lib/error-collector/github.ts +3 -0
- package/templates/standard/workers/lib/error-collector/types.ts +9 -1
package/dist/templates.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Tier } from './prompts.js';
|
|
8
8
|
/** Single source of truth for the SDK version. */
|
|
9
|
-
export declare const SDK_VERSION = "1.4.
|
|
9
|
+
export declare const SDK_VERSION = "1.4.2";
|
|
10
10
|
/** Returns true if `to` is the same or higher tier than `from`. */
|
|
11
11
|
export declare function isTierUpgradeOrSame(from: Tier, to: Tier): boolean;
|
|
12
12
|
/** Check if a template file is a numbered migration (not seed.sql). */
|
package/dist/templates.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All other files are copied verbatim.
|
|
6
6
|
*/
|
|
7
7
|
/** Single source of truth for the SDK version. */
|
|
8
|
-
export const SDK_VERSION = '1.4.
|
|
8
|
+
export const SDK_VERSION = '1.4.2';
|
|
9
9
|
/** Tier ordering for upgrade validation. */
|
|
10
10
|
const TIER_ORDER = { minimal: 0, standard: 1, full: 2 };
|
|
11
11
|
/** Returns true if `to` is the same or higher tier than `from`. */
|
package/package.json
CHANGED
|
@@ -49,6 +49,22 @@ const GITHUB_ORG = 'your-github-org';
|
|
|
49
49
|
// Rate limit: max issues per script per hour
|
|
50
50
|
const MAX_ISSUES_PER_SCRIPT_PER_HOUR = 10;
|
|
51
51
|
|
|
52
|
+
/** Safe parse of KV-cached JSON. Returns null on malformed data and logs the bad key. */
|
|
53
|
+
function safeParseKV<T>(raw: string, kvKey: string): T | null {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(raw) as T;
|
|
56
|
+
} catch {
|
|
57
|
+
console.error(`Malformed KV cache at ${kvKey}, will fall through to D1`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Discriminated result from GitHub issue search */
|
|
63
|
+
type IssueSearchResult =
|
|
64
|
+
| { found: true; issue: { number: number; state: 'open' | 'closed'; shouldSkip: boolean } }
|
|
65
|
+
| { found: false }
|
|
66
|
+
| { found: 'error'; error: unknown };
|
|
67
|
+
|
|
52
68
|
/**
|
|
53
69
|
* Map error type to GitHub issue type
|
|
54
70
|
* - Exceptions, CPU/memory limits, soft errors → Bug
|
|
@@ -244,31 +260,33 @@ async function createDashboardNotification(
|
|
|
244
260
|
* Search GitHub for an existing issue with this fingerprint.
|
|
245
261
|
* Used as a fallback when D1/KV don't have the mapping.
|
|
246
262
|
*
|
|
247
|
-
*
|
|
263
|
+
* Returns a discriminated union so the caller can distinguish
|
|
264
|
+
* "no issue exists" from "search failed" — preventing duplicate
|
|
265
|
+
* issue creation when the GitHub Search API is temporarily unavailable.
|
|
248
266
|
*/
|
|
249
267
|
async function findExistingIssueByFingerprint(
|
|
250
268
|
github: GitHubClient,
|
|
251
269
|
owner: string,
|
|
252
270
|
repo: string,
|
|
253
271
|
fingerprint: string
|
|
254
|
-
): Promise<{
|
|
255
|
-
number: number;
|
|
256
|
-
state: 'open' | 'closed';
|
|
257
|
-
shouldSkip: boolean; // true if muted/wontfix
|
|
258
|
-
} | null> {
|
|
272
|
+
): Promise<IssueSearchResult> {
|
|
259
273
|
try {
|
|
260
274
|
// Search for issues containing this fingerprint in the body
|
|
261
275
|
// The fingerprint appears as "Fingerprint: `{hash}`" in the issue body
|
|
262
|
-
const issues = await github.searchIssues(
|
|
276
|
+
const issues = await github.searchIssues(
|
|
277
|
+
owner,
|
|
278
|
+
repo,
|
|
279
|
+
`"Fingerprint: \`${fingerprint}\`" in:body`
|
|
280
|
+
);
|
|
263
281
|
|
|
264
|
-
if (issues.length === 0) return
|
|
282
|
+
if (issues.length === 0) return { found: false };
|
|
265
283
|
|
|
266
284
|
// Prefer open issues over closed
|
|
267
285
|
const openIssue = issues.find((i) => i.state === 'open');
|
|
268
286
|
if (openIssue) {
|
|
269
287
|
const labelNames = openIssue.labels.map((l) => l.name);
|
|
270
288
|
const shouldSkip = SKIP_REOPEN_LABELS.some((l) => labelNames.includes(l));
|
|
271
|
-
return { number: openIssue.number, state: 'open', shouldSkip };
|
|
289
|
+
return { found: true, issue: { number: openIssue.number, state: 'open', shouldSkip } };
|
|
272
290
|
}
|
|
273
291
|
|
|
274
292
|
// Return most recent closed issue (search results are sorted by best match)
|
|
@@ -277,14 +295,18 @@ async function findExistingIssueByFingerprint(
|
|
|
277
295
|
const shouldSkip = SKIP_REOPEN_LABELS.some((l) => labelNames.includes(l));
|
|
278
296
|
|
|
279
297
|
return {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
298
|
+
found: true,
|
|
299
|
+
issue: {
|
|
300
|
+
number: closedIssue.number,
|
|
301
|
+
state: closedIssue.state as 'open' | 'closed',
|
|
302
|
+
shouldSkip,
|
|
303
|
+
},
|
|
283
304
|
};
|
|
284
305
|
} catch (error) {
|
|
285
|
-
// Fail
|
|
306
|
+
// Fail closed — if search fails, skip issue creation to prevent duplicates.
|
|
307
|
+
// The error is still tracked in D1; next occurrence will retry the search.
|
|
286
308
|
console.error(`GitHub search failed for fingerprint ${fingerprint}:`, error);
|
|
287
|
-
return
|
|
309
|
+
return { found: 'error', error };
|
|
288
310
|
}
|
|
289
311
|
}
|
|
290
312
|
|
|
@@ -358,48 +380,53 @@ async function getOrCreateOccurrence(
|
|
|
358
380
|
const cached = await kv.get(kvKey);
|
|
359
381
|
|
|
360
382
|
if (cached) {
|
|
361
|
-
const data =
|
|
383
|
+
const data = safeParseKV<{
|
|
362
384
|
issueNumber?: number;
|
|
363
385
|
issueUrl?: string;
|
|
364
386
|
status: ErrorStatus;
|
|
365
387
|
occurrenceCount: number;
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
JSON.stringify({
|
|
386
|
-
...data,
|
|
387
|
-
occurrenceCount: data.occurrenceCount + 1,
|
|
388
|
-
lastSeen: Date.now(),
|
|
389
|
-
}),
|
|
390
|
-
{ expirationTtl: 90 * 24 * 60 * 60 }
|
|
391
|
-
); // 90 days
|
|
388
|
+
}>(cached, kvKey);
|
|
389
|
+
|
|
390
|
+
if (!data) {
|
|
391
|
+
// Corrupted cache — delete and fall through to D1
|
|
392
|
+
await kv.delete(kvKey);
|
|
393
|
+
} else {
|
|
394
|
+
// Update occurrence count in D1
|
|
395
|
+
await db
|
|
396
|
+
.prepare(
|
|
397
|
+
`
|
|
398
|
+
UPDATE error_occurrences
|
|
399
|
+
SET occurrence_count = occurrence_count + 1,
|
|
400
|
+
last_seen_at = unixepoch(),
|
|
401
|
+
updated_at = unixepoch()
|
|
402
|
+
WHERE fingerprint = ?
|
|
403
|
+
`
|
|
404
|
+
)
|
|
405
|
+
.bind(fingerprint)
|
|
406
|
+
.run();
|
|
392
407
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
408
|
+
// Update KV cache
|
|
409
|
+
await kv.put(
|
|
410
|
+
kvKey,
|
|
411
|
+
JSON.stringify({
|
|
412
|
+
...data,
|
|
413
|
+
occurrenceCount: data.occurrenceCount + 1,
|
|
414
|
+
lastSeen: Date.now(),
|
|
415
|
+
}),
|
|
416
|
+
{ expirationTtl: 90 * 24 * 60 * 60 }
|
|
417
|
+
); // 90 days
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
isNew: false,
|
|
421
|
+
occurrence: {
|
|
422
|
+
id: fingerprint,
|
|
423
|
+
occurrence_count: data.occurrenceCount + 1,
|
|
424
|
+
github_issue_number: data.issueNumber,
|
|
425
|
+
github_issue_url: data.issueUrl,
|
|
426
|
+
status: data.status,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
403
430
|
}
|
|
404
431
|
|
|
405
432
|
// Check D1 for existing occurrence
|
|
@@ -542,7 +569,8 @@ async function updateOccurrenceWithIssue(
|
|
|
542
569
|
const kvKey = `ERROR_FINGERPRINT:${fingerprint}`;
|
|
543
570
|
const cached = await kv.get(kvKey);
|
|
544
571
|
if (cached) {
|
|
545
|
-
const data =
|
|
572
|
+
const data = safeParseKV<Record<string, unknown>>(cached, kvKey);
|
|
573
|
+
if (!data) return; // Corrupted cache — D1 is already updated
|
|
546
574
|
await kv.put(
|
|
547
575
|
kvKey,
|
|
548
576
|
JSON.stringify({
|
|
@@ -665,6 +693,12 @@ function formatIssueBody(
|
|
|
665
693
|
body += `🟠 Exceeded CPU Limit\n\n`;
|
|
666
694
|
} else if (errorType === 'memory_limit') {
|
|
667
695
|
body += `🟠 Exceeded Memory Limit\n\n`;
|
|
696
|
+
} else if (errorType === 'canceled') {
|
|
697
|
+
body += `🟠 Invocation Canceled\n\n`;
|
|
698
|
+
} else if (errorType === 'stream_disconnected') {
|
|
699
|
+
body += `🟡 Response Stream Disconnected\n\n`;
|
|
700
|
+
} else if (errorType === 'script_not_found') {
|
|
701
|
+
body += `🔴 Script Not Found (Deployment Issue)\n\n`;
|
|
668
702
|
} else if (errorType === 'soft_error') {
|
|
669
703
|
const errorLog = event.logs.find((l) => l.level === 'error');
|
|
670
704
|
const cleanMsg = errorLog ? extractCoreMessage(errorLog.message[0]) : 'Unknown';
|
|
@@ -959,9 +993,18 @@ async function handleNewOrRecurringError(ctx: {
|
|
|
959
993
|
|
|
960
994
|
try {
|
|
961
995
|
// DEDUP CHECK: Search GitHub for existing issue with this fingerprint
|
|
962
|
-
const
|
|
996
|
+
const searchResult = await findExistingIssueByFingerprint(github, owner, repo, fingerprint);
|
|
997
|
+
|
|
998
|
+
// If search failed, skip issue creation to prevent duplicates.
|
|
999
|
+
// The error is still tracked in D1; next occurrence will retry.
|
|
1000
|
+
if (searchResult.found === 'error') {
|
|
1001
|
+
console.log(`GitHub search failed for ${fingerprint}, skipping issue creation to prevent duplicates`);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (searchResult.found === true) {
|
|
1006
|
+
const existingIssue = searchResult.issue;
|
|
963
1007
|
|
|
964
|
-
if (existingIssue) {
|
|
965
1008
|
// Check if issue is muted/wontfix - don't reopen or create new
|
|
966
1009
|
if (existingIssue.shouldSkip) {
|
|
967
1010
|
console.log(`Issue #${existingIssue.number} is muted/wontfix, skipping`);
|
|
@@ -1472,9 +1515,15 @@ async function tail(events: TailEvent[], env: Env): Promise<void> {
|
|
|
1472
1515
|
|
|
1473
1516
|
// Load dynamic patterns once for all events in this batch
|
|
1474
1517
|
// These are AI-suggested, human-approved patterns from pattern-discovery
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1518
|
+
// Defensive wrap: if SDK's internal catch is ever refactored, this prevents entire batch loss
|
|
1519
|
+
let dynamicPatterns: CompiledPattern[] = [];
|
|
1520
|
+
try {
|
|
1521
|
+
dynamicPatterns = await loadDynamicPatterns(env.PLATFORM_CACHE);
|
|
1522
|
+
if (dynamicPatterns.length > 0) {
|
|
1523
|
+
console.log(`Loaded ${dynamicPatterns.length} dynamic patterns from KV`);
|
|
1524
|
+
}
|
|
1525
|
+
} catch (e) {
|
|
1526
|
+
console.error('Failed to load dynamic patterns in tail handler:', e);
|
|
1478
1527
|
}
|
|
1479
1528
|
|
|
1480
1529
|
for (const event of events) {
|
|
@@ -48,6 +48,21 @@ export function shouldCapture(event: TailEvent): CaptureDecision {
|
|
|
48
48
|
return { capture: true, type: 'exception' };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Canceled invocations (workflow timeout, step failure) - always capture
|
|
52
|
+
if (event.outcome === 'canceled') {
|
|
53
|
+
return { capture: true, type: 'canceled' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Response stream disconnected (client disconnect during streaming)
|
|
57
|
+
if (event.outcome === 'responseStreamDisconnected') {
|
|
58
|
+
return { capture: true, type: 'stream_disconnected' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Script not found (deployment issue) - always capture as critical
|
|
62
|
+
if (event.outcome === 'scriptNotFound') {
|
|
63
|
+
return { capture: true, type: 'script_not_found' };
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
// Check for soft errors (console.error with 'ok' outcome)
|
|
52
67
|
const hasErrorLogs = event.logs.some((l) => l.level === 'error');
|
|
53
68
|
if (hasErrorLogs) {
|
|
@@ -72,8 +87,12 @@ export function calculatePriority(
|
|
|
72
87
|
tier: number,
|
|
73
88
|
occurrenceCount: number
|
|
74
89
|
): Priority {
|
|
75
|
-
// Resource limits are always critical
|
|
76
|
-
if (
|
|
90
|
+
// Resource limits and deployment failures are always critical
|
|
91
|
+
if (
|
|
92
|
+
errorType === 'cpu_limit' ||
|
|
93
|
+
errorType === 'memory_limit' ||
|
|
94
|
+
errorType === 'script_not_found'
|
|
95
|
+
) {
|
|
77
96
|
return 'P0';
|
|
78
97
|
}
|
|
79
98
|
|
|
@@ -84,6 +103,16 @@ export function calculatePriority(
|
|
|
84
103
|
return 'P2'; // Tier 2+ = Medium priority
|
|
85
104
|
}
|
|
86
105
|
|
|
106
|
+
// Canceled invocations — moderate priority (workflow issues)
|
|
107
|
+
if (errorType === 'canceled') {
|
|
108
|
+
return 'P2';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Stream disconnected — low priority (often transient/client-side)
|
|
112
|
+
if (errorType === 'stream_disconnected') {
|
|
113
|
+
return 'P3';
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
// Soft errors escalate with repeated occurrences
|
|
88
117
|
if (errorType === 'soft_error') {
|
|
89
118
|
return occurrenceCount > 5 ? 'P2' : 'P3';
|
|
@@ -132,6 +161,15 @@ export function getLabels(errorType: ErrorType, priority: Priority): string[] {
|
|
|
132
161
|
case 'soft_error':
|
|
133
162
|
labels.push('cf:error:soft-error');
|
|
134
163
|
break;
|
|
164
|
+
case 'canceled':
|
|
165
|
+
labels.push('cf:error:canceled');
|
|
166
|
+
break;
|
|
167
|
+
case 'stream_disconnected':
|
|
168
|
+
labels.push('cf:error:stream-disconnected');
|
|
169
|
+
break;
|
|
170
|
+
case 'script_not_found':
|
|
171
|
+
labels.push('cf:error:script-not-found');
|
|
172
|
+
break;
|
|
135
173
|
case 'warning':
|
|
136
174
|
labels.push('cf:error:warning');
|
|
137
175
|
break;
|
|
@@ -201,6 +239,18 @@ export function formatErrorTitle(
|
|
|
201
239
|
}
|
|
202
240
|
}
|
|
203
241
|
|
|
242
|
+
if (errorType === 'canceled') {
|
|
243
|
+
return `[${scriptName}] Invocation canceled`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (errorType === 'stream_disconnected') {
|
|
247
|
+
return `[${scriptName}] Response stream disconnected`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (errorType === 'script_not_found') {
|
|
251
|
+
return `[${scriptName}] Script not found`;
|
|
252
|
+
}
|
|
253
|
+
|
|
204
254
|
if (errorType === 'warning') {
|
|
205
255
|
const warnLog = event.logs.find((l) => l.level === 'warn');
|
|
206
256
|
if (warnLog) {
|
|
@@ -73,6 +73,7 @@ async function getInstallationToken(
|
|
|
73
73
|
`https://api.github.com/app/installations/${installationId}/access_tokens`,
|
|
74
74
|
{
|
|
75
75
|
method: 'POST',
|
|
76
|
+
signal: AbortSignal.timeout(10_000),
|
|
76
77
|
headers: {
|
|
77
78
|
Authorization: `Bearer ${jwt}`,
|
|
78
79
|
Accept: 'application/vnd.github+json',
|
|
@@ -135,6 +136,7 @@ export class GitHubClient {
|
|
|
135
136
|
|
|
136
137
|
const response = await fetch(`https://api.github.com${path}`, {
|
|
137
138
|
method,
|
|
139
|
+
signal: AbortSignal.timeout(10_000),
|
|
138
140
|
headers: {
|
|
139
141
|
Authorization: `Bearer ${token}`,
|
|
140
142
|
Accept: 'application/vnd.github+json',
|
|
@@ -235,6 +237,7 @@ export class GitHubClient {
|
|
|
235
237
|
|
|
236
238
|
const response = await fetch('https://api.github.com/graphql', {
|
|
237
239
|
method: 'POST',
|
|
240
|
+
signal: AbortSignal.timeout(10_000),
|
|
238
241
|
headers: {
|
|
239
242
|
Authorization: `Bearer ${token}`,
|
|
240
243
|
'Content-Type': 'application/json',
|
|
@@ -83,7 +83,15 @@ export interface TailEvent {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Error Collector types
|
|
86
|
-
export type ErrorType =
|
|
86
|
+
export type ErrorType =
|
|
87
|
+
| 'exception'
|
|
88
|
+
| 'cpu_limit'
|
|
89
|
+
| 'memory_limit'
|
|
90
|
+
| 'canceled'
|
|
91
|
+
| 'stream_disconnected'
|
|
92
|
+
| 'script_not_found'
|
|
93
|
+
| 'soft_error'
|
|
94
|
+
| 'warning';
|
|
87
95
|
export type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
|
88
96
|
export type ErrorStatus = 'open' | 'resolved' | 'wont_fix' | 'pending_digest' | 'digested';
|
|
89
97
|
|