@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.
@@ -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.0";
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.0';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/platform-admin-sdk",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Platform Admin SDK — scaffold backend infrastructure with workers, circuit breakers, and cost protection for Cloudflare",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- * @returns Issue details if found, null otherwise
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(owner, repo, `"Fingerprint: \`${fingerprint}\`" in:body`);
276
+ const issues = await github.searchIssues(
277
+ owner,
278
+ repo,
279
+ `"Fingerprint: \`${fingerprint}\`" in:body`
280
+ );
263
281
 
264
- if (issues.length === 0) return null;
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
- number: closedIssue.number,
281
- state: closedIssue.state as 'open' | 'closed',
282
- shouldSkip,
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 open - if search fails, allow creating a new issue
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 null;
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 = JSON.parse(cached) as {
383
+ const data = safeParseKV<{
362
384
  issueNumber?: number;
363
385
  issueUrl?: string;
364
386
  status: ErrorStatus;
365
387
  occurrenceCount: number;
366
- };
367
-
368
- // Update occurrence count in D1
369
- await db
370
- .prepare(
371
- `
372
- UPDATE error_occurrences
373
- SET occurrence_count = occurrence_count + 1,
374
- last_seen_at = unixepoch(),
375
- updated_at = unixepoch()
376
- WHERE fingerprint = ?
377
- `
378
- )
379
- .bind(fingerprint)
380
- .run();
381
-
382
- // Update KV cache
383
- await kv.put(
384
- kvKey,
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
- return {
394
- isNew: false,
395
- occurrence: {
396
- id: fingerprint,
397
- occurrence_count: data.occurrenceCount + 1,
398
- github_issue_number: data.issueNumber,
399
- github_issue_url: data.issueUrl,
400
- status: data.status,
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 = JSON.parse(cached);
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 existingIssue = await findExistingIssueByFingerprint(github, owner, repo, fingerprint);
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
- const dynamicPatterns = await loadDynamicPatterns(env.PLATFORM_CACHE);
1476
- if (dynamicPatterns.length > 0) {
1477
- console.log(`Loaded ${dynamicPatterns.length} dynamic patterns from KV`);
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 (errorType === 'cpu_limit' || errorType === 'memory_limit') {
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 = 'exception' | 'cpu_limit' | 'memory_limit' | 'soft_error' | 'warning';
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