@detiq/crawler 1.1.21 → 1.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/crawler.ts CHANGED
@@ -102,7 +102,7 @@ const _crawlLLMCache = new Map<string, any>();
102
102
  async function getCachedCrawlLLM(tenantId: string): Promise<any> {
103
103
  if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
104
104
  const cfg = await getTaskAgentLLMConfig(tenantId, 'crawl').catch(() => null);
105
- _crawlLLMCache.set(tenantId, cfg);
105
+ if (cfg) _crawlLLMCache.set(tenantId, cfg);
106
106
  return cfg;
107
107
  }
108
108
 
@@ -128,7 +128,9 @@ interface PerformanceMetric {
128
128
  const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
129
129
  const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
130
130
  const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
131
- const NUMERIC_ID_PATH_SEGMENT_RE = /^\d[\d-]*$/;
131
+ // Match 4+ digit pure numeric IDs only — aligns with canonicalize()'s /\d{4,}/ threshold.
132
+ // 1-3 digit segments (page/2/, /tag/books/page/3/) are valid pagination — do NOT skip them.
133
+ const NUMERIC_ID_PATH_SEGMENT_RE = /^\d{4,}$/;
132
134
  const COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE = /^\d+-\d+$/;
133
135
 
134
136
  function normalizePathForNumericGuard(pathname: string): string {
@@ -1955,6 +1957,7 @@ export async function crawlProject(opts: {
1955
1957
  };
1956
1958
  const edges: Array<{ fromUrl: string; toUrl: string }> = [];
1957
1959
  const pendingTransitions: Array<{ fromScreenId: string; elementMeaning: string; targetUrl: string }> = [];
1960
+ const pendingSummaryWrites: Promise<void>[] = [];
1958
1961
  const discovered: Array<{
1959
1962
  url: string; screenId?: string; elements?: number;
1960
1963
  elementFacts?: Array<{ meaning: string; role: string; expectedData?: string }>;
@@ -3450,6 +3453,7 @@ export async function crawlProject(opts: {
3450
3453
  const summaryPromise = (async () => {
3451
3454
  try {
3452
3455
  const llmCfg = await getCachedCrawlLLM(opts.tenantId);
3456
+ if (!llmCfg) return undefined;
3453
3457
  const snippet = extracted.markdown.slice(0, 1200);
3454
3458
  // Skip LLM when content is too thin — output would be useless
3455
3459
  if (snippet.trim().length < 80) return undefined;
@@ -3487,9 +3491,15 @@ export async function crawlProject(opts: {
3487
3491
  heading: extracted.title || name,
3488
3492
  }).catch((err: any) => console.warn('[crawler] markdown extraction save failed:', err));
3489
3493
 
3490
- summaryPromise.then((aiSummary) => {
3491
- if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch(() => { });
3492
- });
3494
+ pendingSummaryWrites.push(
3495
+ summaryPromise.then((aiSummary) => {
3496
+ if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch((e: any) => {
3497
+ logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary DB save failed');
3498
+ });
3499
+ }).catch((e: any) => {
3500
+ logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary generation failed');
3501
+ })
3502
+ );
3493
3503
  }
3494
3504
 
3495
3505
  const structuredJson = summarizeStructuredElements(atElements);
@@ -3989,9 +3999,11 @@ export async function crawlProject(opts: {
3989
3999
 
3990
4000
  // Discovery mode: return collected URLs and skip all post-crawl analysis phases.
3991
4001
  // Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
4002
+ // Filter out sentinel strings (_pending_, _auth_wall_, _soft404_, etc.) that mark
4003
+ // in-progress or skipped entries — only keep actual http(s) URLs.
3992
4004
  if (opts.discoveryMode) {
3993
4005
  return {
3994
- discoveredUrls: Array.from(visited.values()),
4006
+ discoveredUrls: Array.from(visited.values()).filter((v: string) => v.startsWith('http')),
3995
4007
  screensFound: 0,
3996
4008
  screensErrored: 0,
3997
4009
  screensSkippedToNextSegment: 0,
@@ -4768,6 +4780,15 @@ export async function crawlProject(opts: {
4768
4780
  const screensSucceeded = discovered.filter(d => !!d.screenId).length;
4769
4781
  const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
4770
4782
 
4783
+ // Drain pending AI summary writes before marking the job done — ensures descriptions
4784
+ // are visible immediately after crawl completes. Cap wait at 30 s per screen, 60 s total.
4785
+ if (pendingSummaryWrites.length > 0) {
4786
+ await Promise.race([
4787
+ Promise.allSettled(pendingSummaryWrites),
4788
+ new Promise<void>((resolve) => setTimeout(resolve, Math.min(60_000, pendingSummaryWrites.length * 3_000))),
4789
+ ]);
4790
+ }
4791
+
4771
4792
  // Detect systematic element/link extraction failure across all screens
4772
4793
  if (screensSucceeded >= 3) {
4773
4794
  const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
package/src/job-queue.ts CHANGED
@@ -86,7 +86,7 @@ export function generateTOTP(secret: string): string {
86
86
  const hmac = createHmac('sha1', Buffer.from(bytes)).update(buf).digest();
87
87
  const offset = hmac[hmac.length - 1] & 0xf;
88
88
  const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) |
89
- ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
89
+ ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
90
90
  return String(code % 1_000_000).padStart(6, '0');
91
91
  }
92
92
 
@@ -94,12 +94,12 @@ function deriveFallbackTargetPath(ref: string, framework: string): string {
94
94
  const slug = (ref ?? 'test').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'test';
95
95
  const base = framework.startsWith('cypress') ? 'cypress/e2e/'
96
96
  : (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? 'tests/'
97
- : (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'src/test/java/'
98
- : 'tests/';
97
+ : (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'src/test/java/'
98
+ : 'tests/';
99
99
  const ext = (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? '_test.py'
100
100
  : (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'Test.java'
101
- : framework.includes('ts') ? '.spec.ts'
102
- : '.spec.js';
101
+ : framework.includes('ts') ? '.spec.ts'
102
+ : '.spec.js';
103
103
  return `${base}${slug}${ext}`;
104
104
  }
105
105
 
@@ -109,23 +109,23 @@ function makeJobName(type: string): string {
109
109
  const ts = now.getTime();
110
110
  const label = type === 'CRAWL' ? 'Crawl'
111
111
  : type === 'CAPTURE_STEPS' ? 'CaptureSteps'
112
- : type === 'NATIVE_CRAWL' ? 'NativeCrawl'
113
- : type === 'GENERATE_TESTS' ? 'GenerateTests'
114
- : type === 'VISUAL_REGRESSION' ? 'VisualRegression'
115
- : type === 'AI_EDIT' ? 'AIEdit'
116
- : type === 'API_TESTS' ? 'ApiTests'
117
- : type === 'SECURITY_TESTS' ? 'SecurityTests'
118
- : type === 'INTEGRATION_TESTS' ? 'IntegrationTests'
119
- : type === 'GENERATE_TESTS_FROM_REQS' ? 'GenerateTestsFromReqs'
120
- : type === 'GENERATE_DATA' ? 'GenerateData'
121
- : type === 'GENERATE_SCRIPT' ? 'GenerateScript'
122
- : type === 'GENERATE_TEST_PLAN' ? 'GenerateTestPlan'
123
- : type === 'IMPORT_TESTS' ? 'ImportTests'
124
- : type === 'GENERATE_SCREEN_TESTS' ? 'GenerateScreenTests'
125
- : type === 'BATCH_GENERATE_ALIGNED' ? 'BatchGenerateAligned'
126
- : type === 'CREATE_PR' ? 'CreatePR'
127
- : type === 'NEW_PAGES_PIPELINE' ? 'NewPagesPipeline'
128
- : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
112
+ : type === 'NATIVE_CRAWL' ? 'NativeCrawl'
113
+ : type === 'GENERATE_TESTS' ? 'GenerateTests'
114
+ : type === 'VISUAL_REGRESSION' ? 'VisualRegression'
115
+ : type === 'AI_EDIT' ? 'AIEdit'
116
+ : type === 'API_TESTS' ? 'ApiTests'
117
+ : type === 'SECURITY_TESTS' ? 'SecurityTests'
118
+ : type === 'INTEGRATION_TESTS' ? 'IntegrationTests'
119
+ : type === 'GENERATE_TESTS_FROM_REQS' ? 'GenerateTestsFromReqs'
120
+ : type === 'GENERATE_DATA' ? 'GenerateData'
121
+ : type === 'GENERATE_SCRIPT' ? 'GenerateScript'
122
+ : type === 'GENERATE_TEST_PLAN' ? 'GenerateTestPlan'
123
+ : type === 'IMPORT_TESTS' ? 'ImportTests'
124
+ : type === 'GENERATE_SCREEN_TESTS' ? 'GenerateScreenTests'
125
+ : type === 'BATCH_GENERATE_ALIGNED' ? 'BatchGenerateAligned'
126
+ : type === 'CREATE_PR' ? 'CreatePR'
127
+ : type === 'NEW_PAGES_PIPELINE' ? 'NewPagesPipeline'
128
+ : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
129
129
  return `${label}_${date}_${ts}`;
130
130
  }
131
131
 
@@ -195,6 +195,9 @@ const WORKER_ID = `${os.hostname()}:${process.pid}`;
195
195
  const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
196
196
  const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
197
197
  const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
198
+ // Max simultaneous DISCOVER jobs — each launches a Playwright browser and holds
199
+ // DB connections, so running many concurrently exhausts resources. Default 2.
200
+ const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
198
201
  const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
199
202
  // How many headless browsers a single RUN_TEST_SUITE job launches at once —
200
203
  // distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
@@ -221,14 +224,21 @@ export async function claimSpecificJob(jobId: string): Promise<ClaimedJob | null
221
224
  }
222
225
 
223
226
  /** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
224
- * race-safe against any number of other pollers doing the same thing concurrently. */
227
+ * race-safe against any number of other pollers doing the same thing concurrently.
228
+ * DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
229
+ * already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
225
230
  export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
231
+ const maxDiscover = MAX_CONCURRENT_DISCOVER;
226
232
  const rows = await prismaAdmin.$queryRaw<ClaimedJob[]>`
227
233
  UPDATE "PlatformJob"
228
234
  SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
229
235
  WHERE id = (
230
236
  SELECT id FROM "PlatformJob"
231
237
  WHERE status = 'QUEUED'
238
+ AND (
239
+ type != 'DISCOVER'
240
+ OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
241
+ )
232
242
  ORDER BY "queuedAt" ASC
233
243
  FOR UPDATE SKIP LOCKED
234
244
  LIMIT 1
@@ -315,7 +325,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
315
325
  await prismaAdmin.platformJob.update({
316
326
  where: { id: job.id },
317
327
  data: { status: 'FAILED', error: `Unknown job type: ${job.type}`, finishedAt: new Date() },
318
- }).catch(() => {});
328
+ }).catch(() => { });
319
329
  return;
320
330
  }
321
331
  const stopTimer = crawlJobDurationSeconds.startTimer({ type: job.type });
@@ -335,7 +345,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
335
345
  await prismaAdmin.platformJob.update({
336
346
  where: { id: job.id },
337
347
  data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
338
- }).catch(() => {});
348
+ }).catch(() => { });
339
349
  }
340
350
  }
341
351
 
@@ -390,7 +400,7 @@ export function stopJobWorkerLoop(): void {
390
400
  /** Create a job record that tracks a synchronous operation (telemetry only, no background processor). */
391
401
  export async function createTrackedJob(
392
402
  type: string,
393
- config: { tenantId: string; projectId: string; [k: string]: any }
403
+ config: { tenantId: string; projectId: string;[k: string]: any }
394
404
  ): Promise<string> {
395
405
  const name = makeJobName(type);
396
406
  const job = await withTenant(config.tenantId, (tx) =>
@@ -414,7 +424,7 @@ export async function finishTrackedJob(tenantId: string, jobId: string, error?:
414
424
  ...(error ? { error, nextRetryAt } : { progress: 100 }),
415
425
  },
416
426
  })
417
- ).catch(() => {});
427
+ ).catch(() => { });
418
428
  }
419
429
 
420
430
  /** Enqueue a generic platform job — returns the job ID immediately. */
@@ -691,11 +701,11 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
691
701
  // Build runtime credentials: convert stored totpSecret → getOneTimeCode function
692
702
  const runtimeCreds = config.credentials
693
703
  ? {
694
- ...config.credentials,
695
- ...(config.credentials.totpSecret
696
- ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
697
- : {}),
698
- }
704
+ ...config.credentials,
705
+ ...(config.credentials.totpSecret
706
+ ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
707
+ : {}),
708
+ }
699
709
  : undefined;
700
710
 
701
711
  const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
@@ -997,11 +1007,11 @@ export async function aggregateJobTokens(tenantId: string, jobId: string, projec
997
1007
  await tx.platformJob.update({
998
1008
  where: { id: jobId },
999
1009
  data: {
1000
- totalPromptTokens: row.totalPromptTokens,
1010
+ totalPromptTokens: row.totalPromptTokens,
1001
1011
  totalCompletionTokens: row.totalCompletionTokens,
1002
- totalCacheReadTokens: row.totalCacheReadTokens,
1012
+ totalCacheReadTokens: row.totalCacheReadTokens,
1003
1013
  totalEstimatedCostUsd: Number(row.totalEstimatedCostUsd),
1004
- llmCallCount: row.llmCallCount,
1014
+ llmCallCount: row.llmCallCount,
1005
1015
  },
1006
1016
  });
1007
1017
  });
@@ -1180,10 +1190,10 @@ async function processFigmaImportJob(jobId: string, config: any) {
1180
1190
  const figmaElements = figma.extractElements((frame as any).node);
1181
1191
  for (const el of figmaElements) {
1182
1192
  const role = el.type === 'COMPONENT' || el.type === 'COMPONENT_SET' ? 'COMPONENT'
1183
- : el.type === 'INSTANCE' ? 'COMPONENT'
1184
- : el.type === 'TEXT' ? 'TEXT'
1185
- : el.type === 'FRAME' || el.type === 'GROUP' ? 'REGION'
1186
- : el.type;
1193
+ : el.type === 'INSTANCE' ? 'COMPONENT'
1194
+ : el.type === 'TEXT' ? 'TEXT'
1195
+ : el.type === 'FRAME' || el.type === 'GROUP' ? 'REGION'
1196
+ : el.type;
1187
1197
  const boundingRect = el.boundingBox
1188
1198
  ? { x: Math.round(el.boundingBox.x), y: Math.round(el.boundingBox.y), width: Math.round(el.boundingBox.width), height: Math.round(el.boundingBox.height) }
1189
1199
  : undefined;
@@ -1261,7 +1271,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1261
1271
  deviceFarm,
1262
1272
  },
1263
1273
  })
1264
- ).catch(() => {});
1274
+ ).catch(() => { });
1265
1275
 
1266
1276
  const workspaceDeviceFarm = await AppBrain.getTenantDeviceFarmConfig(tenantId).catch(() => null);
1267
1277
 
@@ -1328,7 +1338,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1328
1338
  } catch (err: any) {
1329
1339
  await withTenant(tenantId, (tx) =>
1330
1340
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
1331
- ).catch(() => {});
1341
+ ).catch(() => { });
1332
1342
  }
1333
1343
  }
1334
1344
 
@@ -1387,7 +1397,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1387
1397
  try {
1388
1398
  const reqSeed = deterministicSeed(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
1389
1399
  const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
1390
- if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => {});
1400
+ if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
1391
1401
  const json = text.match(/\{[\s\S]*\}/)?.[0];
1392
1402
  if (json) parsed = JSON.parse(json);
1393
1403
  } catch (e: any) {
@@ -1396,8 +1406,8 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1396
1406
 
1397
1407
  await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
1398
1408
 
1399
- const VALID_KINDS = new Set(['POSITIVE','NEGATIVE','EDGE','STATE_TRANSITION','ACCESSIBILITY','SECURITY','INTEGRATION','E2E','API','EXPLORATORY','UAT','PERFORMANCE','COMPATIBILITY','L10N','USABILITY','LOAD','UNIT','MUTATION','AB_TEST','ALPHA_BETA','SYSTEM','SMOKE','REGRESSION','SANITY']);
1400
- const VALID_PRIORITIES = new Set(['CRITICAL','HIGH','MEDIUM','LOW']);
1409
+ const VALID_KINDS = new Set(['POSITIVE', 'NEGATIVE', 'EDGE', 'STATE_TRANSITION', 'ACCESSIBILITY', 'SECURITY', 'INTEGRATION', 'E2E', 'API', 'EXPLORATORY', 'UAT', 'PERFORMANCE', 'COMPATIBILITY', 'L10N', 'USABILITY', 'LOAD', 'UNIT', 'MUTATION', 'AB_TEST', 'ALPHA_BETA', 'SYSTEM', 'SMOKE', 'REGRESSION', 'SANITY']);
1410
+ const VALID_PRIORITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
1401
1411
  const normalized = (parsed.cases ?? []).map((c: any) => ({
1402
1412
  ref: c.reqRef ?? null,
1403
1413
  traceTo: c.reqRef ?? null,
@@ -1419,7 +1429,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1419
1429
  await Promise.all(
1420
1430
  reqs
1421
1431
  .filter((r: any) => coveredReqIds.has(r.reqId))
1422
- .map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => {}))
1432
+ .map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => { }))
1423
1433
  );
1424
1434
 
1425
1435
  await withTenant(tenantId, (tx) =>
@@ -1433,7 +1443,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1433
1443
  logger.error({ jobId, err: msg }, 'processGenerateTestsFromReqsJob failed');
1434
1444
  await withTenant(tenantId, (tx) =>
1435
1445
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
1436
- ).catch(() => {});
1446
+ ).catch(() => { });
1437
1447
  }
1438
1448
  }
1439
1449
 
@@ -1544,7 +1554,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1544
1554
  } : {}),
1545
1555
  });
1546
1556
  }
1547
- await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => {});
1557
+ await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => { });
1548
1558
  } catch (err: any) {
1549
1559
  failed++;
1550
1560
  runResults.push({ testCaseId: tc.id, status: 'ERROR' });
@@ -1553,7 +1563,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1553
1563
  testCaseId: tc.id,
1554
1564
  status: 'ERROR',
1555
1565
  error: String(err?.message ?? err).slice(0, 500),
1556
- }).catch(() => {});
1566
+ }).catch(() => { });
1557
1567
  }
1558
1568
  }));
1559
1569
  await updateJobProgress(
@@ -1592,10 +1602,10 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1592
1602
  } catch (err: any) {
1593
1603
  const msg = String(err?.message ?? err).slice(0, 500);
1594
1604
  logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
1595
- await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {});
1605
+ await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => { });
1596
1606
  await withTenant(tenantId, (tx) =>
1597
1607
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
1598
- ).catch(() => {});
1608
+ ).catch(() => { });
1599
1609
  }
1600
1610
  }
1601
1611
 
@@ -1840,12 +1850,12 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1840
1850
  await onProgress(52, 'Authenticated spider…');
1841
1851
  const runtimeCreds = crawlCfg.username
1842
1852
  ? {
1843
- username: crawlCfg.username,
1844
- password: crawlCfg.password ?? '',
1845
- ...(crawlCfg.totpSecret
1846
- ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret!) }
1847
- : {}),
1848
- }
1853
+ username: crawlCfg.username,
1854
+ password: crawlCfg.password ?? '',
1855
+ ...(crawlCfg.totpSecret
1856
+ ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret!) }
1857
+ : {}),
1858
+ }
1849
1859
  : undefined;
1850
1860
 
1851
1861
  const authResult = await crawlProject({
@@ -1897,7 +1907,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1897
1907
  }
1898
1908
 
1899
1909
  // Persist discovered URLs to project crawl config
1900
- const discoveredUrls = Array.from(allUrls);
1910
+ // Filter sentinel strings (_pending_, _auth_wall_, etc.) that may have leaked in
1911
+ // via onUrlsDiscovered callbacks fired before visited-map entries were updated.
1912
+ const discoveredUrls = Array.from(allUrls).filter(u => u.startsWith('http'));
1901
1913
  const existingCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => ({}));
1902
1914
  await AppBrain.saveCrawlConfig(tenantId, projectId, {
1903
1915
  ...(existingCfg as any),
@@ -2164,7 +2176,7 @@ async function processImportTestsJob(jobId: string, config: any) {
2164
2176
  status: lr.needsReview ? 'PENDING_REVIEW' : undefined,
2165
2177
  },
2166
2178
  })
2167
- ).catch(() => {}) // non-fatal per-case
2179
+ ).catch(() => { }) // non-fatal per-case
2168
2180
  )
2169
2181
  );
2170
2182
  labeled = labelResults.length;
@@ -2258,7 +2270,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2258
2270
  testCaseId: tc.id,
2259
2271
  styleProfile: conn.styleProfile,
2260
2272
  pageObjectInventory: conn.pageObjectInventory ?? undefined,
2261
- }).catch(() => {})
2273
+ }).catch(() => { })
2262
2274
  );
2263
2275
  }
2264
2276
  }
@@ -2272,7 +2284,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2272
2284
  logger.error({ jobId, screenId, err: msg }, 'processGenerateScreenTestsJob failed');
2273
2285
  await withTenant(tenantId, (tx: any) =>
2274
2286
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg.slice(0, 500), finishedAt: new Date() } })
2275
- ).catch(() => {});
2287
+ ).catch(() => { });
2276
2288
  }
2277
2289
  }
2278
2290
 
@@ -2424,7 +2436,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2424
2436
  heartbeatAt: new Date(),
2425
2437
  },
2426
2438
  })
2427
- ).catch(() => {});
2439
+ ).catch(() => { });
2428
2440
 
2429
2441
  try {
2430
2442
  const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
@@ -2433,7 +2445,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2433
2445
  result.healTier = heal.healedVia;
2434
2446
  result.healReasoning = heal.healReasoning;
2435
2447
  }
2436
- await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => {});
2448
+ await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => { });
2437
2449
  result.status = result.healed ? 'HEALED' : 'PASSED';
2438
2450
  } catch (err: any) {
2439
2451
  result.status = step.optional ? 'SKIPPED' : 'FAILED';
@@ -2444,7 +2456,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2444
2456
  result.url = currentUrl;
2445
2457
  const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === 'FAILED' ? '-err' : ''}.png`;
2446
2458
  const ssPath = path.join(screenshotDir, ssFilename);
2447
- await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {});
2459
+ await page.screenshot({ path: ssPath, fullPage: false }).catch(() => { });
2448
2460
  result.screenshotPath = ssPath;
2449
2461
 
2450
2462
  let cloudUrl: string | undefined;
@@ -2466,13 +2478,13 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2466
2478
  const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? 'ENTRY_POINT' : 'LINK_FOLLOW').catch(() => null) as any;
2467
2479
  if (screen?.id) {
2468
2480
  result.screenId = screen.id;
2469
- await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => {});
2481
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => { });
2470
2482
  for (const el of elements) {
2471
2483
  await AppBrain.saveElement(
2472
2484
  tenantId, projectId, screen.id,
2473
2485
  el.meaning, el.role, el.expectedData, el.notes,
2474
2486
  undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
2475
- ).catch(() => {});
2487
+ ).catch(() => { });
2476
2488
  }
2477
2489
  for (const loc of elementsToPlaywrightLocators(elements as any)) {
2478
2490
  if (seenLocatorExpressions.has(loc.expression)) continue;
@@ -2491,7 +2503,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2491
2503
  }
2492
2504
 
2493
2505
  if (locators.length > 0) {
2494
- await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => {});
2506
+ await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => { });
2495
2507
  }
2496
2508
 
2497
2509
  const firstScreen = screens[0];
@@ -2503,7 +2515,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2503
2515
  captureStepScreenshots: stepResults as any,
2504
2516
  },
2505
2517
  })
2506
- ).catch(() => {});
2518
+ ).catch(() => { });
2507
2519
 
2508
2520
  const finalResult = {
2509
2521
  stepResults,
@@ -2522,11 +2534,11 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2522
2534
  const error = String(err?.message ?? err);
2523
2535
  await withTenant(tenantId, (tx) =>
2524
2536
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
2525
- ).catch(() => {});
2537
+ ).catch(() => { });
2526
2538
  logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
2527
2539
  throw err;
2528
2540
  } finally {
2529
- await browser?.close?.().catch(() => {});
2541
+ await browser?.close?.().catch(() => { });
2530
2542
  }
2531
2543
  }
2532
2544
 
@@ -2562,28 +2574,28 @@ jobDispatchTable = {
2562
2574
  await prismaAdmin.platformJob.update({
2563
2575
  where: { id: jobId },
2564
2576
  data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2565
- }).catch(() => {});
2577
+ }).catch(() => { });
2566
2578
  },
2567
2579
  GENERATE_DATA: async (jobId: string, _config: any) => {
2568
2580
  logger.error({ jobId }, '[job-worker] GENERATE_DATA claimed by worker — API did not process it in-process');
2569
2581
  await prismaAdmin.platformJob.update({
2570
2582
  where: { id: jobId },
2571
2583
  data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2572
- }).catch(() => {});
2584
+ }).catch(() => { });
2573
2585
  },
2574
2586
  GENERATE_SCRIPT: async (jobId: string, _config: any) => {
2575
2587
  logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
2576
2588
  await prismaAdmin.platformJob.update({
2577
2589
  where: { id: jobId },
2578
2590
  data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2579
- }).catch(() => {});
2591
+ }).catch(() => { });
2580
2592
  },
2581
2593
  GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
2582
2594
  logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
2583
2595
  await prismaAdmin.platformJob.update({
2584
2596
  where: { id: jobId },
2585
2597
  data: { status: 'FAILED', error: 'Test plan generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2586
- }).catch(() => {});
2598
+ }).catch(() => { });
2587
2599
  },
2588
2600
  };
2589
2601
 
@@ -2880,14 +2892,14 @@ async function processRepoScanJob(jobId: string, config: any) {
2880
2892
  const testPathSet = new Set(testPaths);
2881
2893
  const sourceFilePaths = (language === 'typescript' || language === 'javascript')
2882
2894
  ? treeResult.paths
2883
- .filter((p: string) =>
2884
- (p.endsWith('.ts') || p.endsWith('.js')) &&
2885
- !testPathSet.has(p) &&
2886
- !p.endsWith('.d.ts') &&
2887
- !p.includes('node_modules/') &&
2888
- !p.includes('dist/') &&
2889
- !p.includes('.config.'))
2890
- .slice(0, 60)
2895
+ .filter((p: string) =>
2896
+ (p.endsWith('.ts') || p.endsWith('.js')) &&
2897
+ !testPathSet.has(p) &&
2898
+ !p.endsWith('.d.ts') &&
2899
+ !p.includes('node_modules/') &&
2900
+ !p.includes('dist/') &&
2901
+ !p.includes('.config.'))
2902
+ .slice(0, 60)
2891
2903
  : [];
2892
2904
  const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
2893
2905
  // Pass full tree paths separately so detectDataDirs can find testdata/*.json, fixtures/*.yaml etc.
@@ -3019,10 +3031,10 @@ async function processRepoScanJob(jobId: string, config: any) {
3019
3031
  logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
3020
3032
  await withTenant(tenantId, (tx) =>
3021
3033
  tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })
3022
- ).catch(() => {});
3034
+ ).catch(() => { });
3023
3035
  await withTenant(tenantId, (tx) =>
3024
3036
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3025
- ).catch(() => {});
3037
+ ).catch(() => { });
3026
3038
  }
3027
3039
  }
3028
3040
 
@@ -3147,7 +3159,7 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
3147
3159
  logger.error({ jobId, err: msg }, '[batch-generate-aligned] failed');
3148
3160
  await withTenant(tenantId, (tx) =>
3149
3161
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3150
- ).catch(() => {});
3162
+ ).catch(() => { });
3151
3163
  }
3152
3164
  }
3153
3165
 
@@ -3276,7 +3288,7 @@ async function processCreatePRJob(jobId: string, config: any) {
3276
3288
  logger.error({ jobId, err: msg }, '[create-pr] failed');
3277
3289
  await withTenant(tenantId, (tx) =>
3278
3290
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3279
- ).catch(() => {});
3291
+ ).catch(() => { });
3280
3292
  }
3281
3293
  }
3282
3294
 
@@ -3380,6 +3392,6 @@ async function processNewPagesPipelineJob(jobId: string, config: any) {
3380
3392
  logger.error({ jobId, err: msg }, '[new-pages-pipeline] failed');
3381
3393
  await withTenant(tenantId, (tx) =>
3382
3394
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3383
- ).catch(() => {});
3395
+ ).catch(() => { });
3384
3396
  }
3385
3397
  }
@@ -78,7 +78,9 @@ type ClaimedJob = {
78
78
  /** Atomically claims one specific QUEUED job for this worker. Returns null if it was already claimed. */
79
79
  export declare function claimSpecificJob(jobId: string): Promise<ClaimedJob | null>;
80
80
  /** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
81
- * race-safe against any number of other pollers doing the same thing concurrently. */
81
+ * race-safe against any number of other pollers doing the same thing concurrently.
82
+ * DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
83
+ * already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
82
84
  export declare function claimNextQueuedJob(): Promise<ClaimedJob | null>;
83
85
  /** Requeues RUNNING jobs whose heartbeat has gone stale — the worker that claimed them
84
86
  * died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and