@detiq/crawler 1.1.22 → 1.1.24

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.
Files changed (40) hide show
  1. package/dist/crawler.js +173 -28
  2. package/dist/crawler.js.map +2 -2
  3. package/dist/extractors/branding-extractor.js +104 -0
  4. package/dist/extractors/branding-extractor.js.map +7 -0
  5. package/dist/extractors/summary-extractor.js +36 -0
  6. package/dist/extractors/summary-extractor.js.map +7 -0
  7. package/dist/frontier.js +20 -2
  8. package/dist/frontier.js.map +2 -2
  9. package/dist/incremental-diff.js +76 -0
  10. package/dist/incremental-diff.js.map +2 -2
  11. package/dist/index.js +3 -1
  12. package/dist/index.js.map +2 -2
  13. package/dist/interactive-probe.js +108 -17
  14. package/dist/interactive-probe.js.map +2 -2
  15. package/dist/job-queue.js +430 -67
  16. package/dist/job-queue.js.map +2 -2
  17. package/dist/sqs-queue.js +78 -10
  18. package/dist/sqs-queue.js.map +2 -2
  19. package/dist/worker.js +7 -7
  20. package/dist/worker.js.map +2 -2
  21. package/package.json +1 -1
  22. package/src/crawler.ts +208 -30
  23. package/src/extractors/branding-extractor.ts +159 -0
  24. package/src/extractors/summary-extractor.ts +49 -0
  25. package/src/frontier.ts +31 -1
  26. package/src/incremental-diff.ts +138 -0
  27. package/src/index.ts +1 -1
  28. package/src/interactive-probe.ts +129 -20
  29. package/src/job-queue.ts +525 -69
  30. package/src/sqs-queue.ts +92 -12
  31. package/src/worker.ts +13 -11
  32. package/types/crawler.d.ts +20 -0
  33. package/types/extractors/branding-extractor.d.ts +46 -0
  34. package/types/extractors/summary-extractor.d.ts +10 -0
  35. package/types/frontier.d.ts +9 -1
  36. package/types/incremental-diff.d.ts +57 -0
  37. package/types/index.d.ts +1 -1
  38. package/types/interactive-probe.d.ts +5 -1
  39. package/types/job-queue.d.ts +53 -0
  40. package/types/sqs-queue.d.ts +12 -1
package/src/job-queue.ts CHANGED
@@ -178,6 +178,40 @@ export interface PlatformJobConfig {
178
178
  ssoScope?: string;
179
179
  /** HTTP/HTTPS/SOCKS5 proxy URL — routes browser through this proxy for bot-protected sites. */
180
180
  proxyUrl?: string;
181
+ // ─── scrapegraph gap fields ──────────────────────────────────────────────
182
+ /** HTML extraction mode. 'normal'=raw, 'reader'=Readability article, 'prune'=strip nav/ads. Default: 'normal'. */
183
+ htmlMode?: 'normal' | 'reader' | 'prune';
184
+ /** Per-request screenshot dimensions and quality. Defaults: 1440×900, quality 80. */
185
+ screenshotConfig?: {
186
+ fullPage?: boolean;
187
+ width?: number;
188
+ height?: number;
189
+ quality?: number;
190
+ };
191
+ /** Enable/disable Playwright stealth plugin. Default: true (existing behaviour). */
192
+ stealth?: boolean;
193
+ /** Mock mode — intercept all navigation and return synthetic content. Good for CI. */
194
+ mock?: boolean | { minKb?: number; maxKb?: number; minSleep?: number; maxSleep?: number };
195
+ /** MIME type allowlist — crawler skips URLs returning disallowed content types. */
196
+ allowedTypes?: string[];
197
+ /** Glob-style include patterns for URL paths (e.g. '/blog/*'). Only matching paths crawled. */
198
+ includePatterns?: string[];
199
+ /** Glob-style exclude patterns for URL paths (e.g. '/admin/*'). Matching paths skipped. */
200
+ excludePatterns?: string[];
201
+ /** Allow crawler to follow links to external domains. Default: false. */
202
+ allowExternal?: boolean;
203
+ /** Requested output formats for a single-pass multi-format extraction. */
204
+ requestedFormats?: Array<'markdown' | 'html' | 'links' | 'images' | 'summary' | 'json' | 'branding' | 'screenshot'>;
205
+ /** LLM chunker configuration for AI extraction passes. */
206
+ chunkerConfig?: { size?: number | 'dynamic'; overlap?: number };
207
+ /** ISO-3166-1 alpha-2 country code for geo-targeted proxy routing (e.g. 'us', 'gb'). */
208
+ proxyCountry?: string;
209
+ /** When true, after the primary crawl auto-enqueue one CRAWL job per credentialProfile. */
210
+ crawlAllRoles?: boolean;
211
+ /** Decrypted credential profiles used when crawlAllRoles is true. */
212
+ credentialProfiles?: Array<{ id: string; name: string; username: string; password: string }>;
213
+ /** Set on role-crawl sub-jobs — profile name tagged on discovered screens via roleAccess column. */
214
+ roleProfileName?: string;
181
215
  }
182
216
 
183
217
  /** In-memory map of jobId → AbortController for RUNNING jobs. */
@@ -318,7 +352,7 @@ export async function requeueOrphanedJobs(): Promise<void> {
318
352
 
319
353
  /** Dispatches a claimed job to its type's processor. Last-resort safety net — every
320
354
  * process*Job function already catches and records its own failures. */
321
- async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
355
+ export async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
322
356
  const dispatcher = jobDispatchTable?.[job.type];
323
357
  if (!dispatcher) {
324
358
  logger.error({ jobId: job.id, type: job.type }, '[job-worker] no dispatcher registered for job type');
@@ -344,13 +378,58 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
344
378
  logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, '[job-worker] job threw uncaught error');
345
379
  await prismaAdmin.platformJob.update({
346
380
  where: { id: job.id },
347
- data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
381
+ data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) },
348
382
  }).catch(() => { });
349
383
  }
350
384
  }
351
385
 
352
386
  let workerLoopTimer: ReturnType<typeof setInterval> | null = null;
353
387
 
388
+ /** Re-promotes FAILED jobs to QUEUED if they are eligible for retry based on per-type limits. */
389
+ export async function retryEligibleFailedJobs(): Promise<void> {
390
+ const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
391
+ UPDATE "PlatformJob"
392
+ SET status = 'QUEUED',
393
+ "retryCount" = "retryCount" + 1,
394
+ "nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
395
+ "workerId" = NULL
396
+ WHERE status = 'FAILED'
397
+ AND dlq = false
398
+ AND "nextRetryAt" <= NOW()
399
+ AND "retryCount" < CASE
400
+ WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
401
+ WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
402
+ WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
403
+ ELSE 1
404
+ END
405
+ RETURNING id
406
+ `;
407
+ if (rows.length > 0) {
408
+ logger.info({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] requeued failed job(s) for retry');
409
+ }
410
+ }
411
+
412
+ /** Promotes FAILED jobs to DLQ if they exceed their per-type retry limits. */
413
+ export async function promoteToDeadLetterQueue(): Promise<void> {
414
+ const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
415
+ UPDATE "PlatformJob"
416
+ SET dlq = true,
417
+ "dlqReason" = error
418
+ WHERE status = 'FAILED'
419
+ AND dlq = false
420
+ AND "retryCount" >= CASE
421
+ WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
422
+ WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
423
+ WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
424
+ ELSE 1
425
+ END
426
+ RETURNING id
427
+ `;
428
+ if (rows.length > 0) {
429
+ logger.error({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] promoted failed job(s) to DLQ');
430
+ }
431
+ }
432
+
354
433
  /**
355
434
  * Starts the durable job worker poll loop. This is what makes job processing
356
435
  * survive a restart — enqueueJob()'s immediate same-process claim attempt is
@@ -367,15 +446,22 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
367
446
 
368
447
  const tick = async () => {
369
448
  try {
449
+ await retryEligibleFailedJobs();
450
+ await promoteToDeadLetterQueue();
370
451
  await requeueStaleRunningJobs();
371
452
  } catch (err) {
372
- logger.error({ err: String(err) }, '[job-worker] stale-job sweep failed');
453
+ logger.error({ err: String(err) }, '[job-worker] stale/retry job sweep failed');
373
454
  }
374
455
  try {
375
- for (let i = 0; i < concurrency; i++) {
376
- const job = await claimNextQueuedJob();
377
- if (!job) break;
378
- void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
456
+ const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
457
+ if (pauseRow?.value === 'true') {
458
+ logger.info('[job-worker] queue is paused — skipping claim');
459
+ } else {
460
+ for (let i = 0; i < concurrency; i++) {
461
+ const job = await claimNextQueuedJob();
462
+ if (!job) break;
463
+ void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
464
+ }
379
465
  }
380
466
  } catch (err) {
381
467
  logger.error({ err: String(err) }, '[job-worker] claim loop failed');
@@ -449,9 +535,9 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
449
535
  // latency optimization so a job doesn't sit idle until the next poll tick. The
450
536
  // claim is atomic (WHERE status='QUEUED'), so it's safe even if the poll loop's
451
537
  // own tick fires concurrently and claims this same job first.
452
- if (type === 'CRAWL' && process.env.JOB_QUEUE_BACKEND === 'sqs') {
538
+ if (process.env.JOB_QUEUE_BACKEND === 'sqs') {
453
539
  const { sendJobToSqs } = await import('./sqs-queue.js');
454
- await sendJobToSqs(job.id, tenantId, config);
540
+ await sendJobToSqs(job.id);
455
541
  } else if (process.env.DISABLE_WORKER !== 'true') {
456
542
  // Skip inline dispatch when DISABLE_WORKER=true — the dedicated crawler process
457
543
  // will pick this up via its poll loop. Inline dispatch in the API container
@@ -906,8 +992,8 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
906
992
  WHERE tc."tenantId" = ${tenantId}
907
993
  AND s."projectId" = ${projectId}
908
994
  AND s."elementHashChangedAt" IS NOT NULL
909
- AND tc."genAt" IS NOT NULL
910
- AND s."elementHashChangedAt" > tc."genAt"
995
+ AND tc."createdAt" IS NOT NULL
996
+ AND s."elementHashChangedAt" > tc."createdAt"
911
997
  AND tc.status != 'PENDING_REVIEW'
912
998
  AND tc.status != 'RETIRED'
913
999
  `;
@@ -962,6 +1048,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
962
1048
  status: aborted ? 'CANCELLED' : 'FAILED',
963
1049
  error: aborted ? 'Stopped by user' : String(err),
964
1050
  finishedAt: new Date(),
1051
+ ...(aborted ? {} : { nextRetryAt: new Date(Date.now() + 60_000) }),
965
1052
  ...(checkpoint ? { result: { checkpoint } as any } : {}),
966
1053
  },
967
1054
  })
@@ -1046,7 +1133,7 @@ async function processApiTestsJob(jobId: string, config: any) {
1046
1133
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1047
1134
  } catch (err: any) {
1048
1135
  await withTenant(tenantId, (tx) =>
1049
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date() } })
1136
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
1050
1137
  );
1051
1138
  }
1052
1139
  }
@@ -1077,7 +1164,7 @@ async function processSecurityTestsJob(jobId: string, config: any) {
1077
1164
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1078
1165
  } catch (err: any) {
1079
1166
  await withTenant(tenantId, (tx) =>
1080
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date() } })
1167
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
1081
1168
  );
1082
1169
  }
1083
1170
  }
@@ -1108,7 +1195,7 @@ async function processIntegrationTestsJob(jobId: string, config: any) {
1108
1195
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1109
1196
  } catch (err: any) {
1110
1197
  await withTenant(tenantId, (tx) =>
1111
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date() } })
1198
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
1112
1199
  );
1113
1200
  }
1114
1201
  }
@@ -1313,8 +1400,8 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1313
1400
  WHERE tc."tenantId" = ${tenantId}
1314
1401
  AND s."projectId" = ${projectId}
1315
1402
  AND s."elementHashChangedAt" IS NOT NULL
1316
- AND tc."genAt" IS NOT NULL
1317
- AND s."elementHashChangedAt" > tc."genAt"
1403
+ AND tc."createdAt" IS NOT NULL
1404
+ AND s."elementHashChangedAt" > tc."createdAt"
1318
1405
  AND tc.status != 'PENDING_REVIEW'
1319
1406
  AND tc.status != 'RETIRED'
1320
1407
  `;
@@ -1858,45 +1945,52 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1858
1945
  }
1859
1946
  : undefined;
1860
1947
 
1861
- const authResult = await crawlProject({
1862
- tenantId,
1863
- projectId,
1864
- appUrl,
1865
- discoveryMode: true,
1866
- credentials: runtimeCreds,
1867
- storageState: crawlCfg.storageState,
1868
- httpBasicUsername: crawlCfg.httpBasicUsername,
1869
- httpBasicPassword: crawlCfg.httpBasicPassword,
1870
- authToken: crawlCfg.authToken,
1871
- authTokenHeader: crawlCfg.authTokenHeader,
1872
- authTokenPrefix: crawlCfg.authTokenPrefix,
1873
- startUrls: crawlCfg.postLoginUrl
1874
- ? [crawlCfg.postLoginUrl, ...(sitemapUrls.length > 0 ? sitemapUrls : [])]
1875
- : (sitemapUrls.length > 0 ? sitemapUrls : undefined),
1876
- screenshotDir,
1877
- maxScreens: maxDiscoverUrls,
1878
- proxyUrl,
1879
- chromeProfilePath: crawlCfg.chromeProfilePath,
1880
- useFirefox: crawlCfg.useFirefox,
1881
- storageConfig: discoverStorageConfig,
1882
- captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1883
- captchaSolverProvider: crawlCfg.captchaSolverProvider,
1884
- mailslurpApiKey: crawlCfg.mailslurpApiKey,
1885
- mailslurpInboxId: crawlCfg.mailslurpInboxId,
1886
- loginInstructions: crawlCfg.loginInstructions,
1887
- ...resolvedSso,
1888
- abortSignal: controller.signal,
1889
- jobId,
1890
- onUrlsDiscovered: async (urls: string[]) => {
1891
- for (const u of urls) allUrls.add(u);
1892
- const pct = 52 + Math.round((allUrls.size / Math.max(1, maxDiscoverUrls)) * 38);
1893
- await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)…`);
1894
- },
1895
- }, async (pct: number, step: string) => {
1896
- await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
1897
- }).catch((err: any) => {
1948
+ const PHASE3_TIMEOUT_MS = 20 * 60_000; // 20-minute cap on authenticated spider
1949
+ const authResult = await Promise.race([
1950
+ crawlProject({
1951
+ tenantId,
1952
+ projectId,
1953
+ appUrl,
1954
+ discoveryMode: true,
1955
+ credentials: runtimeCreds,
1956
+ storageState: crawlCfg.storageState,
1957
+ httpBasicUsername: crawlCfg.httpBasicUsername,
1958
+ httpBasicPassword: crawlCfg.httpBasicPassword,
1959
+ authToken: crawlCfg.authToken,
1960
+ authTokenHeader: crawlCfg.authTokenHeader,
1961
+ authTokenPrefix: crawlCfg.authTokenPrefix,
1962
+ startUrls: crawlCfg.postLoginUrl
1963
+ ? [crawlCfg.postLoginUrl, ...(sitemapUrls.length > 0 ? sitemapUrls : [])]
1964
+ : (sitemapUrls.length > 0 ? sitemapUrls : undefined),
1965
+ screenshotDir,
1966
+ maxScreens: maxDiscoverUrls,
1967
+ proxyUrl,
1968
+ chromeProfilePath: crawlCfg.chromeProfilePath,
1969
+ useFirefox: crawlCfg.useFirefox,
1970
+ storageConfig: discoverStorageConfig,
1971
+ captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1972
+ captchaSolverProvider: crawlCfg.captchaSolverProvider,
1973
+ mailslurpApiKey: crawlCfg.mailslurpApiKey,
1974
+ mailslurpInboxId: crawlCfg.mailslurpInboxId,
1975
+ loginInstructions: crawlCfg.loginInstructions,
1976
+ ...resolvedSso,
1977
+ abortSignal: controller.signal,
1978
+ jobId,
1979
+ onUrlsDiscovered: async (urls: string[]) => {
1980
+ for (const u of urls) allUrls.add(u);
1981
+ const pct = 52 + Math.round((allUrls.size / Math.max(1, maxDiscoverUrls)) * 38);
1982
+ await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)…`);
1983
+ },
1984
+ }, async (pct: number, step: string) => {
1985
+ await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
1986
+ }),
1987
+ new Promise<never>((_, rej) => setTimeout(() => rej(new Error('AUTH_PHASE_TIMEOUT')), PHASE3_TIMEOUT_MS)),
1988
+ ]).catch((err: any) => {
1898
1989
  if (err?.message === 'CRAWL_ABORTED') throw err;
1899
- logger.warn({ err: err?.message }, '[discover] Phase 3 authenticated spider failed — using unauthenticated results');
1990
+ const reason = err?.message === 'AUTH_PHASE_TIMEOUT'
1991
+ ? 'Authenticated spider timed out after 20 minutes'
1992
+ : err?.message;
1993
+ logger.warn({ reason }, '[discover] Phase 3 authenticated spider failed — using unauthenticated results');
1900
1994
  return { discoveredUrls: [] as string[] };
1901
1995
  });
1902
1996
 
@@ -2389,10 +2483,35 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2389
2483
  if (!tc) throw new Error('Test case not found');
2390
2484
 
2391
2485
  const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
2392
- const stepTexts = rawSteps.map(captureStepText).filter(Boolean);
2486
+ if (rawSteps.length === 0) throw new Error('Test case has no steps to capture');
2487
+
2488
+ // Resolve env tokens from testDataInline so {{fze.env.URL}} → concrete URL before execution plan
2489
+ const _tdi = ((tc as any).testDataInline ?? {}) as Record<string, unknown>;
2490
+ const _tdiStartUrl = typeof _tdi['startUrl'] === 'string' && (_tdi['startUrl'] as string).startsWith('http')
2491
+ ? (_tdi['startUrl'] as string) : '';
2492
+ const _recordedUrls = Array.isArray(_tdi['recordedUrls']) ? (_tdi['recordedUrls'] as unknown[]).map(String) : [];
2493
+ const _recordedStartUrl = _recordedUrls.find((u) => u.startsWith('http')) ?? '';
2494
+
2495
+ const baseUrl = config.baseUrl || _tdiStartUrl || _recordedStartUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
2496
+
2497
+ // Build env token → concrete value map from testDataInline for step text substitution
2498
+ const _envTokenMap: Record<string, string> = {};
2499
+ for (const [k, v] of Object.entries(_tdi)) {
2500
+ if (typeof v === 'string' && v.startsWith('http')) _envTokenMap[k] = v;
2501
+ }
2502
+ if (_recordedStartUrl && !_envTokenMap['startUrl']) _envTokenMap['startUrl'] = _recordedStartUrl;
2503
+
2504
+ // Replace {{fze.env.X}} and [env:X] tokens in step texts with concrete values from testDataInline.
2505
+ // IMPORTANT: only substitute if we have a concrete value for that key.
2506
+ // Fall back to [key] placeholder — never fall back to baseUrl (would inject URL into credential fields).
2507
+ const resolveStepText = (text: string): string =>
2508
+ text
2509
+ .replace(/\{\{\s*fze\.env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`)
2510
+ .replace(/\[env:([A-Za-z_][A-Za-z0-9_]*)\]/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`);
2511
+
2512
+ const stepTexts = rawSteps.map(captureStepText).filter(Boolean).map(resolveStepText);
2393
2513
  if (stepTexts.length === 0) throw new Error('Test case has no steps to capture');
2394
2514
 
2395
- const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
2396
2515
  let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
2397
2516
  plan = Array.isArray(plan)
2398
2517
  ? plan.filter(Boolean).map((step: any, idx: number) => ({ ...step, stepNumber: idx + 1 }))
@@ -2542,6 +2661,322 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2542
2661
  }
2543
2662
  }
2544
2663
 
2664
+ // ── Multi-role crawl orchestrator ────────────────────────────────────────────
2665
+ // Runs a primary CRAWL to discover all screens, then fans out one ROLE_CRAWL
2666
+ // job per credential profile. Role jobs run concurrently via the worker pool.
2667
+
2668
+ async function processMultiRoleCrawlJob(jobId: string, config: any) {
2669
+ const { tenantId, projectId } = config;
2670
+ try {
2671
+ await withTenant(tenantId, (tx) =>
2672
+ tx.platformJob.updateMany({
2673
+ where: { id: jobId, status: { not: 'CANCELLED' } },
2674
+ data: { status: 'RUNNING', startedAt: new Date(), heartbeatAt: new Date(), workerId: WORKER_ID },
2675
+ })
2676
+ );
2677
+
2678
+ await updateJobProgress(tenantId, jobId, 5, 'Loading project crawl config…');
2679
+
2680
+ // Load saved project crawlConfig for primary crawl parameters
2681
+ const proj = await withTenant(tenantId, (tx) =>
2682
+ (tx as any).project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
2683
+ ) as { crawlConfig: any } | null;
2684
+ const savedCrawlConfig = proj?.crawlConfig ?? {};
2685
+
2686
+ const credentialProfiles: Array<{ id: string; name: string; username: string; password: string }> =
2687
+ config.credentialProfiles ?? [];
2688
+
2689
+ // ── Step 1: enqueue primary CRAWL ────────────────────────────────────────
2690
+ await updateJobProgress(tenantId, jobId, 10, 'Enqueuing primary crawl…');
2691
+ const primaryJobId = await enqueueJob('CRAWL', {
2692
+ tenantId,
2693
+ projectId,
2694
+ appUrl: config.appUrl || savedCrawlConfig.appUrl,
2695
+ credentials: savedCrawlConfig.username && savedCrawlConfig.password
2696
+ ? { username: savedCrawlConfig.username, password: savedCrawlConfig.password, ...(savedCrawlConfig.totpSecret ? { totpSecret: savedCrawlConfig.totpSecret } : {}) }
2697
+ : undefined,
2698
+ storageState: savedCrawlConfig.storageState,
2699
+ screenshotDir: platformConfig.screenshotDir,
2700
+ startUrls: savedCrawlConfig.seedUrls?.length ? savedCrawlConfig.seedUrls : undefined,
2701
+ deviceProfiles: savedCrawlConfig.deviceProfiles ?? ['DESKTOP'],
2702
+ loginInstructions: savedCrawlConfig.loginInstructions,
2703
+ proxyUrl: savedCrawlConfig.proxyUrl,
2704
+ useSitemap: true,
2705
+ sitemapUrl: savedCrawlConfig.sitemapUrl,
2706
+ });
2707
+ logger.info({ primaryJobId, projectId }, '[multi-role-crawl] primary CRAWL enqueued');
2708
+
2709
+ // ── Step 2: poll until primary CRAWL is done (2-hour timeout) ────────────
2710
+ const PRIMARY_TIMEOUT_MS = 2 * 60 * 60 * 1000;
2711
+ const POLL_INTERVAL_MS = 10_000;
2712
+ const deadline = Date.now() + PRIMARY_TIMEOUT_MS;
2713
+ let primaryStatus = 'QUEUED';
2714
+ while (Date.now() < deadline) {
2715
+ const primaryJob = await prismaAdmin.platformJob.findUnique({
2716
+ where: { id: primaryJobId },
2717
+ select: { status: true },
2718
+ }).catch(() => null);
2719
+ primaryStatus = primaryJob?.status ?? 'UNKNOWN';
2720
+ if (primaryStatus === 'DONE' || primaryStatus === 'FAILED' || primaryStatus === 'CANCELLED') break;
2721
+ await updateJobProgress(tenantId, jobId, 20, `Waiting for primary crawl (${primaryStatus})…`);
2722
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
2723
+ }
2724
+
2725
+ if (primaryStatus !== 'DONE') {
2726
+ throw new Error(`Primary crawl ${primaryJobId} ended with status ${primaryStatus} — aborting role crawls`);
2727
+ }
2728
+
2729
+ // ── Step 3: collect all known screen URLs ────────────────────────────────
2730
+ await updateJobProgress(tenantId, jobId, 50, 'Collecting screen URLs for role crawls…');
2731
+ const screens = await prismaAdmin.screen.findMany({
2732
+ where: { tenantId, projectId, status: 'ACTIVE' },
2733
+ select: { id: true, url: true },
2734
+ }).catch(() => [] as Array<{ id: string; url: string | null }>);
2735
+ const screenUrls = screens.filter((s) => s.url).map((s) => s.url as string);
2736
+ logger.info({ screenCount: screenUrls.length, projectId }, '[multi-role-crawl] screen URLs collected');
2737
+
2738
+ // ── Step 4: enqueue one ROLE_CRAWL per credential profile ────────────────
2739
+ if (credentialProfiles.length === 0) {
2740
+ logger.warn({ projectId, jobId }, '[multi-role-crawl] no credential profiles — nothing to role-crawl');
2741
+ }
2742
+
2743
+ const roleJobs: Array<{ roleName: string; jobId: string }> = [];
2744
+ for (const profile of credentialProfiles) {
2745
+ if (!profile.username || !profile.password) continue;
2746
+ const roleJobId = await enqueueJob('ROLE_CRAWL', {
2747
+ tenantId,
2748
+ projectId,
2749
+ appUrl: config.appUrl || savedCrawlConfig.appUrl,
2750
+ credentials: { username: profile.username, password: profile.password },
2751
+ roleProfileName: profile.name,
2752
+ parentJobId: jobId,
2753
+ selectiveUrls: config.fullReCrawl ? undefined : (screenUrls.length > 0 ? screenUrls : undefined),
2754
+ startUrls: config.fullReCrawl ? (savedCrawlConfig.seedUrls ?? []) : undefined,
2755
+ maxScreens: config.fullReCrawl ? undefined : screenUrls.length + 10,
2756
+ screenshotDir: platformConfig.screenshotDir,
2757
+ storageState: savedCrawlConfig.storageState,
2758
+ deviceProfiles: savedCrawlConfig.deviceProfiles ?? ['DESKTOP'],
2759
+ loginInstructions: savedCrawlConfig.loginInstructions,
2760
+ proxyUrl: savedCrawlConfig.proxyUrl,
2761
+ });
2762
+ roleJobs.push({ roleName: profile.name, jobId: roleJobId });
2763
+ logger.info({ roleJobId, roleName: profile.name, projectId }, '[multi-role-crawl] ROLE_CRAWL enqueued');
2764
+ }
2765
+
2766
+ // ── Step 5: poll until ALL role crawls complete ───────────────────────────
2767
+ const ROLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
2768
+ const roleDeadline = Date.now() + ROLE_TIMEOUT_MS;
2769
+ const roleResults: Array<{ roleName: string; jobId: string; status: string; screensTagged?: number }> = [];
2770
+
2771
+ while (Date.now() < roleDeadline && roleJobs.length > 0) {
2772
+ const pending: typeof roleJobs = [];
2773
+ for (const rj of roleJobs) {
2774
+ const rJob = await prismaAdmin.platformJob.findUnique({
2775
+ where: { id: rj.jobId },
2776
+ select: { status: true, result: true },
2777
+ }).catch(() => null);
2778
+ const st = rJob?.status ?? 'UNKNOWN';
2779
+ if (st === 'DONE' || st === 'FAILED' || st === 'CANCELLED') {
2780
+ roleResults.push({
2781
+ roleName: rj.roleName,
2782
+ jobId: rj.jobId,
2783
+ status: st,
2784
+ screensTagged: (rJob?.result as any)?.screensTagged,
2785
+ });
2786
+ } else {
2787
+ pending.push(rj);
2788
+ }
2789
+ }
2790
+ roleJobs.length = 0;
2791
+ roleJobs.push(...pending);
2792
+ if (roleJobs.length === 0) break;
2793
+ const pct = Math.round(60 + ((roleResults.length / (roleResults.length + roleJobs.length)) * 35));
2794
+ await updateJobProgress(tenantId, jobId, pct, `Role crawls: ${roleResults.length} done, ${roleJobs.length} pending…`);
2795
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
2796
+ }
2797
+
2798
+ // Any still-pending jobs timed out — record them as timed out
2799
+ for (const rj of roleJobs) {
2800
+ roleResults.push({ roleName: rj.roleName, jobId: rj.jobId, status: 'TIMEOUT' });
2801
+ }
2802
+
2803
+ await withTenant(tenantId, (tx) =>
2804
+ tx.platformJob.update({
2805
+ where: { id: jobId },
2806
+ data: {
2807
+ status: 'DONE',
2808
+ progress: 100,
2809
+ finishedAt: new Date(),
2810
+ result: { primaryJobId, roleJobs: roleResults } as any,
2811
+ },
2812
+ })
2813
+ );
2814
+ logger.info({ jobId, primaryJobId, roleCount: roleResults.length, projectId }, '[multi-role-crawl] completed');
2815
+ } catch (err: any) {
2816
+ const msg = String(err?.message ?? err);
2817
+ logger.error({ jobId, err: msg }, '[multi-role-crawl] failed');
2818
+ await withTenant(tenantId, (tx) =>
2819
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg, finishedAt: new Date() } })
2820
+ ).catch(() => { });
2821
+ }
2822
+ }
2823
+
2824
+ // ── Role-specific sub-crawl ───────────────────────────────────────────────────
2825
+ // Crawls the app as a specific credential profile and tags all discovered
2826
+ // screens with the role name in the roleAccess array column.
2827
+
2828
+ async function processRoleCrawlJob(jobId: string, config: any) {
2829
+ const { tenantId, projectId } = config;
2830
+ try {
2831
+ const claimed = await withTenant(tenantId, (tx) =>
2832
+ tx.platformJob.updateMany({
2833
+ where: { id: jobId, status: { not: 'CANCELLED' } },
2834
+ data: { status: 'RUNNING', startedAt: new Date(), heartbeatAt: new Date(), workerId: WORKER_ID },
2835
+ })
2836
+ );
2837
+ if (claimed.count === 0) return;
2838
+
2839
+ const controller = new AbortController();
2840
+ runningControllers.set(jobId, controller);
2841
+ const heartbeatTimer = setInterval(() => void heartbeatJob(tenantId, jobId), JOB_HEARTBEAT_INTERVAL_MS);
2842
+
2843
+ const onProgress = async (pct: number, step: string) => {
2844
+ await updateJobProgress(tenantId, jobId, pct, step);
2845
+ };
2846
+
2847
+ try {
2848
+ // Load project crawlConfig for proxy, storageState overrides
2849
+ const proj = await withTenant(tenantId, (tx) =>
2850
+ (tx as any).project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
2851
+ ) as { crawlConfig: any } | null;
2852
+ const savedCrawlConfig = proj?.crawlConfig ?? {};
2853
+
2854
+ // Build runtime credentials with TOTP support
2855
+ const runtimeCreds = config.credentials
2856
+ ? {
2857
+ ...config.credentials,
2858
+ ...(config.credentials.totpSecret
2859
+ ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
2860
+ : {}),
2861
+ }
2862
+ : undefined;
2863
+
2864
+ const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
2865
+
2866
+ const result = await crawlProject({
2867
+ ...config,
2868
+ credentials: runtimeCreds,
2869
+ storageState: config.storageState ?? savedCrawlConfig.storageState,
2870
+ proxyUrl: config.proxyUrl ?? savedCrawlConfig.proxyUrl,
2871
+ loginInstructions: config.loginInstructions ?? savedCrawlConfig.loginInstructions,
2872
+ abortSignal: controller.signal,
2873
+ jobId,
2874
+ storageConfig: storageConfigForCrawl,
2875
+ }, onProgress);
2876
+
2877
+ clearInterval(heartbeatTimer);
2878
+ runningControllers.delete(jobId);
2879
+
2880
+ // Tag discovered screens with this role
2881
+ const roleName = config.roleProfileName as string;
2882
+ const discovered: any[] = (result as any).discovered ?? [];
2883
+ const roleScreenIds = discovered.filter((d: any) => d.screenId).map((d: any) => d.screenId as string);
2884
+
2885
+ if (roleScreenIds.length > 0 && roleName) {
2886
+ await prismaAdmin.$executeRaw`
2887
+ UPDATE "Screen"
2888
+ SET "roleAccess" = array(SELECT DISTINCT unnest(array_append("roleAccess", ${roleName}::text)))
2889
+ WHERE id = ANY(${roleScreenIds}::text[])
2890
+ AND "tenantId" = ${tenantId}
2891
+ `.catch((e: any) => logger.warn({ err: e?.message, roleName, count: roleScreenIds.length }, '[role-crawl] roleAccess tag failed (non-fatal)'));
2892
+ }
2893
+
2894
+ await withTenant(tenantId, (tx) =>
2895
+ tx.platformJob.update({
2896
+ where: { id: jobId },
2897
+ data: {
2898
+ status: 'DONE',
2899
+ progress: 100,
2900
+ finishedAt: new Date(),
2901
+ result: { screensTagged: roleScreenIds.length, roleName } as any,
2902
+ },
2903
+ })
2904
+ );
2905
+ logger.info({ jobId, roleName, screensTagged: roleScreenIds.length, projectId }, '[role-crawl] completed');
2906
+ } catch (err: any) {
2907
+ clearInterval(heartbeatTimer);
2908
+ runningControllers.delete(jobId);
2909
+ const aborted = String(err).includes('CRAWL_ABORTED');
2910
+ await withTenant(tenantId, (tx) =>
2911
+ tx.platformJob.update({
2912
+ where: { id: jobId },
2913
+ data: {
2914
+ status: aborted ? 'CANCELLED' : 'FAILED',
2915
+ error: aborted ? 'Stopped by user' : String(err),
2916
+ finishedAt: new Date(),
2917
+ },
2918
+ })
2919
+ );
2920
+ } finally {
2921
+ runningControllers.delete(jobId);
2922
+ }
2923
+ } catch (err: any) {
2924
+ const msg = String(err?.message ?? err);
2925
+ logger.error({ jobId, err: msg }, '[role-crawl] outer failure');
2926
+ await withTenant(tenantId, (tx) =>
2927
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg, finishedAt: new Date() } })
2928
+ ).catch(() => { });
2929
+ }
2930
+ }
2931
+
2932
+ async function processGenerateScriptJob(jobId: string, config: any): Promise<void> {
2933
+ const { tenantId, projectId, language } = config;
2934
+ try {
2935
+ await updateJobProgress(tenantId, jobId, 1, 'Loading test cases…').catch(() => {});
2936
+
2937
+ if (config.packId) {
2938
+ // Pack script (regression / smoke / sanity / etc.)
2939
+ const { generatePackScript } = await import('@detiq/agents');
2940
+ const result = await generatePackScript({ tenantId, projectId, packType: config.packId, language });
2941
+ await withTenant(tenantId, (tx) =>
2942
+ tx.platformJob.update({
2943
+ where: { id: jobId },
2944
+ data: { status: 'DONE', progress: 100, finishedAt: new Date(), result: result as any },
2945
+ })
2946
+ );
2947
+ } else {
2948
+ // Suite script
2949
+ const { generateSuiteScript } = await import('@detiq/agents');
2950
+ let progressChain: Promise<void> = Promise.resolve();
2951
+ const result = await generateSuiteScript({
2952
+ tenantId, projectId, suiteId: config.suiteId, language,
2953
+ caseIds: config.caseIds ?? undefined,
2954
+ useV2: config.useV2,
2955
+ styleProfile: config.repoStyleProfile ?? undefined,
2956
+ pageObjectInventory: config.repoPageObjects ?? undefined,
2957
+ jobId,
2958
+ onProgress: (done: number, total: number) => {
2959
+ progressChain = progressChain
2960
+ .then(() => updateJobProgress(tenantId, jobId, Math.floor((done / total) * 95), `Generated ${done}/${total} scripts`))
2961
+ .catch(() => {});
2962
+ },
2963
+ });
2964
+ await progressChain.catch(() => {});
2965
+ await import('@detiq/agents')
2966
+ .then(({ refreshScriptInventory }) => refreshScriptInventory(tenantId, projectId))
2967
+ .catch((e: any) => logger.warn({ err: e?.message }, '[generate-script] refreshScriptInventory failed'));
2968
+ await withTenant(tenantId, (tx) =>
2969
+ tx.platformJob.update({
2970
+ where: { id: jobId },
2971
+ data: { status: 'DONE', progress: 100, finishedAt: new Date(), result: result as any },
2972
+ })
2973
+ );
2974
+ }
2975
+ } catch (e: any) {
2976
+ await finishTrackedJob(tenantId, jobId, e?.message ?? 'generate-script failed');
2977
+ }
2978
+ }
2979
+
2545
2980
  // Registers the type→processor dispatch table used by the durable worker (both the
2546
2981
  // enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
2547
2982
  // bottom so it can reference every process*Job function by name; function
@@ -2567,7 +3002,10 @@ jobDispatchTable = {
2567
3002
  BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
2568
3003
  CREATE_PR: processCreatePRJob,
2569
3004
  NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
2570
- // The following job types are processed in-process by the API (never QUEUED).
3005
+ MULTI_ROLE_CRAWL: processMultiRoleCrawlJob,
3006
+ ROLE_CRAWL: processRoleCrawlJob,
3007
+ GENERATE_SCRIPT: processGenerateScriptJob,
3008
+ // The following job types are still processed in-process by the API.
2571
3009
  // If the worker claims one, the API crashed after creating it — mark failed so it's visible.
2572
3010
  GENERATE_TESTS: async (jobId: string, _config: any) => {
2573
3011
  logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
@@ -2583,13 +3021,6 @@ jobDispatchTable = {
2583
3021
  data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2584
3022
  }).catch(() => { });
2585
3023
  },
2586
- GENERATE_SCRIPT: async (jobId: string, _config: any) => {
2587
- logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
2588
- await prismaAdmin.platformJob.update({
2589
- where: { id: jobId },
2590
- data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2591
- }).catch(() => { });
2592
- },
2593
3024
  GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
2594
3025
  logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
2595
3026
  await prismaAdmin.platformJob.update({
@@ -2630,6 +3061,12 @@ async function processRepoScanJob(jobId: string, config: any) {
2630
3061
  const treeResult = await fetchRepoTree(token, conn.repoFullName, 'HEAD');
2631
3062
  if (treeResult.truncated) {
2632
3063
  console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) — detection may be incomplete`);
3064
+ await withTenant(tenantId, (tx) =>
3065
+ tx.gitHubRepoConnection.update({
3066
+ where: { id: repoConnectionId },
3067
+ data: { scanError: 'Repository tree was truncated by GitHub (>100k files) — some test files in deep subdirectories may be missing from this scan.' },
3068
+ })
3069
+ );
2633
3070
  }
2634
3071
 
2635
3072
  const frameworks = detectFramework(treeResult.paths);
@@ -2657,12 +3094,20 @@ async function processRepoScanJob(jobId: string, config: any) {
2657
3094
  /[A-Z]\w*Tests?\.java$/.test(p) ||
2658
3095
  (/\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split('/').pop() ?? ''))
2659
3096
  );
3097
+ // Build list of globs to try — primary + common variants so we don't miss .test.ts alongside .spec.ts
3098
+ const testGlobs = minimatch
3099
+ ? Array.from(new Set([
3100
+ testGlob,
3101
+ ...(testGlob === '**/*.spec.ts' ? ['**/*.test.ts', '**/*.cy.ts'] : []),
3102
+ ...(testGlob === '**/*.spec.js' ? ['**/*.test.js', '**/*.cy.js'] : []),
3103
+ ]))
3104
+ : [];
2660
3105
  const testPaths = treeResult.paths.filter((p) => {
2661
3106
  if (language === 'java') return isJavaTestFile(p);
2662
3107
  if (language === 'python') return isPythonTestFile(p);
2663
- if (minimatch) return minimatch(p, testGlob, { matchBase: true });
2664
- return p.endsWith('.spec.ts') || p.endsWith('.spec.js') || p.endsWith('.test.ts') || p.endsWith('.test.js') || p.endsWith('_test.py') || p.includes('/test_');
2665
- }).slice(0, 500); // cap at 500 files
3108
+ if (testGlobs.length) return testGlobs.some(g => (minimatch as any)(p, g, { matchBase: true }));
3109
+ return p.endsWith('.spec.ts') || p.endsWith('.spec.js') || p.endsWith('.test.ts') || p.endsWith('.test.js') || p.endsWith('.cy.ts') || p.endsWith('.cy.js') || p.endsWith('_test.py') || p.includes('/test_');
3110
+ }).slice(0, 2000); // cap at 2000 test files
2666
3111
 
2667
3112
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2668
3113
 
@@ -3019,6 +3464,17 @@ async function processRepoScanJob(jobId: string, config: any) {
3019
3464
  } catch (e: any) {
3020
3465
  logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
3021
3466
  }
3467
+ try {
3468
+ const { alignSkillFile } = await import('@detiq/agents');
3469
+ await alignSkillFile(tenantId, projectId, {
3470
+ framework,
3471
+ language,
3472
+ styleProfile: resolvedStyleProfile,
3473
+ repoFullName: conn.repoFullName,
3474
+ });
3475
+ } catch (e: any) {
3476
+ logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] alignSkillFile failed (non-fatal)');
3477
+ }
3022
3478
 
3023
3479
  await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
3024
3480
  await withTenant(tenantId, (tx) =>
@@ -3187,7 +3643,7 @@ async function processCreatePRJob(jobId: string, config: any) {
3187
3643
  if (testScriptIds && testScriptIds.length > 0) {
3188
3644
  scripts = await withTenant(tenantId, (tx) =>
3189
3645
  (tx as any).testScript.findMany({
3190
- where: { id: { in: testScriptIds }, tenantId },
3646
+ where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
3191
3647
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
3192
3648
  })
3193
3649
  );
@@ -3195,7 +3651,7 @@ async function processCreatePRJob(jobId: string, config: any) {
3195
3651
  // All scripts that haven't been pushed yet
3196
3652
  scripts = await withTenant(tenantId, (tx) =>
3197
3653
  (tx as any).testScript.findMany({
3198
- where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] } },
3654
+ where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] }, code: { not: null } },
3199
3655
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
3200
3656
  })
3201
3657
  );