@detiq/crawler 1.1.23 → 1.1.25

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 +408 -61
  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 +489 -63
  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,9 +446,11 @@ 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
456
  const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
@@ -454,9 +535,9 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
454
535
  // latency optimization so a job doesn't sit idle until the next poll tick. The
455
536
  // claim is atomic (WHERE status='QUEUED'), so it's safe even if the poll loop's
456
537
  // own tick fires concurrently and claims this same job first.
457
- if (type === 'CRAWL' && process.env.JOB_QUEUE_BACKEND === 'sqs') {
538
+ if (process.env.JOB_QUEUE_BACKEND === 'sqs') {
458
539
  const { sendJobToSqs } = await import('./sqs-queue.js');
459
- await sendJobToSqs(job.id, tenantId, config);
540
+ await sendJobToSqs(job.id);
460
541
  } else if (process.env.DISABLE_WORKER !== 'true') {
461
542
  // Skip inline dispatch when DISABLE_WORKER=true — the dedicated crawler process
462
543
  // will pick this up via its poll loop. Inline dispatch in the API container
@@ -911,8 +992,8 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
911
992
  WHERE tc."tenantId" = ${tenantId}
912
993
  AND s."projectId" = ${projectId}
913
994
  AND s."elementHashChangedAt" IS NOT NULL
914
- AND tc."genAt" IS NOT NULL
915
- AND s."elementHashChangedAt" > tc."genAt"
995
+ AND tc."createdAt" IS NOT NULL
996
+ AND s."elementHashChangedAt" > tc."createdAt"
916
997
  AND tc.status != 'PENDING_REVIEW'
917
998
  AND tc.status != 'RETIRED'
918
999
  `;
@@ -967,6 +1048,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
967
1048
  status: aborted ? 'CANCELLED' : 'FAILED',
968
1049
  error: aborted ? 'Stopped by user' : String(err),
969
1050
  finishedAt: new Date(),
1051
+ ...(aborted ? {} : { nextRetryAt: new Date(Date.now() + 60_000) }),
970
1052
  ...(checkpoint ? { result: { checkpoint } as any } : {}),
971
1053
  },
972
1054
  })
@@ -1051,7 +1133,7 @@ async function processApiTestsJob(jobId: string, config: any) {
1051
1133
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1052
1134
  } catch (err: any) {
1053
1135
  await withTenant(tenantId, (tx) =>
1054
- 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) } })
1055
1137
  );
1056
1138
  }
1057
1139
  }
@@ -1082,7 +1164,7 @@ async function processSecurityTestsJob(jobId: string, config: any) {
1082
1164
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1083
1165
  } catch (err: any) {
1084
1166
  await withTenant(tenantId, (tx) =>
1085
- 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) } })
1086
1168
  );
1087
1169
  }
1088
1170
  }
@@ -1113,7 +1195,7 @@ async function processIntegrationTestsJob(jobId: string, config: any) {
1113
1195
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1114
1196
  } catch (err: any) {
1115
1197
  await withTenant(tenantId, (tx) =>
1116
- 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) } })
1117
1199
  );
1118
1200
  }
1119
1201
  }
@@ -1318,8 +1400,8 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1318
1400
  WHERE tc."tenantId" = ${tenantId}
1319
1401
  AND s."projectId" = ${projectId}
1320
1402
  AND s."elementHashChangedAt" IS NOT NULL
1321
- AND tc."genAt" IS NOT NULL
1322
- AND s."elementHashChangedAt" > tc."genAt"
1403
+ AND tc."createdAt" IS NOT NULL
1404
+ AND s."elementHashChangedAt" > tc."createdAt"
1323
1405
  AND tc.status != 'PENDING_REVIEW'
1324
1406
  AND tc.status != 'RETIRED'
1325
1407
  `;
@@ -1863,45 +1945,52 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1863
1945
  }
1864
1946
  : undefined;
1865
1947
 
1866
- const authResult = await crawlProject({
1867
- tenantId,
1868
- projectId,
1869
- appUrl,
1870
- discoveryMode: true,
1871
- credentials: runtimeCreds,
1872
- storageState: crawlCfg.storageState,
1873
- httpBasicUsername: crawlCfg.httpBasicUsername,
1874
- httpBasicPassword: crawlCfg.httpBasicPassword,
1875
- authToken: crawlCfg.authToken,
1876
- authTokenHeader: crawlCfg.authTokenHeader,
1877
- authTokenPrefix: crawlCfg.authTokenPrefix,
1878
- startUrls: crawlCfg.postLoginUrl
1879
- ? [crawlCfg.postLoginUrl, ...(sitemapUrls.length > 0 ? sitemapUrls : [])]
1880
- : (sitemapUrls.length > 0 ? sitemapUrls : undefined),
1881
- screenshotDir,
1882
- maxScreens: maxDiscoverUrls,
1883
- proxyUrl,
1884
- chromeProfilePath: crawlCfg.chromeProfilePath,
1885
- useFirefox: crawlCfg.useFirefox,
1886
- storageConfig: discoverStorageConfig,
1887
- captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1888
- captchaSolverProvider: crawlCfg.captchaSolverProvider,
1889
- mailslurpApiKey: crawlCfg.mailslurpApiKey,
1890
- mailslurpInboxId: crawlCfg.mailslurpInboxId,
1891
- loginInstructions: crawlCfg.loginInstructions,
1892
- ...resolvedSso,
1893
- abortSignal: controller.signal,
1894
- jobId,
1895
- onUrlsDiscovered: async (urls: string[]) => {
1896
- for (const u of urls) allUrls.add(u);
1897
- const pct = 52 + Math.round((allUrls.size / Math.max(1, maxDiscoverUrls)) * 38);
1898
- await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)…`);
1899
- },
1900
- }, async (pct: number, step: string) => {
1901
- await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
1902
- }).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) => {
1903
1989
  if (err?.message === 'CRAWL_ABORTED') throw err;
1904
- 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');
1905
1994
  return { discoveredUrls: [] as string[] };
1906
1995
  });
1907
1996
 
@@ -2572,6 +2661,322 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2572
2661
  }
2573
2662
  }
2574
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
+
2575
2980
  // Registers the type→processor dispatch table used by the durable worker (both the
2576
2981
  // enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
2577
2982
  // bottom so it can reference every process*Job function by name; function
@@ -2597,7 +3002,10 @@ jobDispatchTable = {
2597
3002
  BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
2598
3003
  CREATE_PR: processCreatePRJob,
2599
3004
  NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
2600
- // 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.
2601
3009
  // If the worker claims one, the API crashed after creating it — mark failed so it's visible.
2602
3010
  GENERATE_TESTS: async (jobId: string, _config: any) => {
2603
3011
  logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
@@ -2613,13 +3021,6 @@ jobDispatchTable = {
2613
3021
  data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2614
3022
  }).catch(() => { });
2615
3023
  },
2616
- GENERATE_SCRIPT: async (jobId: string, _config: any) => {
2617
- logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
2618
- await prismaAdmin.platformJob.update({
2619
- where: { id: jobId },
2620
- data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2621
- }).catch(() => { });
2622
- },
2623
3024
  GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
2624
3025
  logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
2625
3026
  await prismaAdmin.platformJob.update({
@@ -2660,6 +3061,12 @@ async function processRepoScanJob(jobId: string, config: any) {
2660
3061
  const treeResult = await fetchRepoTree(token, conn.repoFullName, 'HEAD');
2661
3062
  if (treeResult.truncated) {
2662
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
+ );
2663
3070
  }
2664
3071
 
2665
3072
  const frameworks = detectFramework(treeResult.paths);
@@ -2687,12 +3094,20 @@ async function processRepoScanJob(jobId: string, config: any) {
2687
3094
  /[A-Z]\w*Tests?\.java$/.test(p) ||
2688
3095
  (/\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split('/').pop() ?? ''))
2689
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
+ : [];
2690
3105
  const testPaths = treeResult.paths.filter((p) => {
2691
3106
  if (language === 'java') return isJavaTestFile(p);
2692
3107
  if (language === 'python') return isPythonTestFile(p);
2693
- if (minimatch) return minimatch(p, testGlob, { matchBase: true });
2694
- return p.endsWith('.spec.ts') || p.endsWith('.spec.js') || p.endsWith('.test.ts') || p.endsWith('.test.js') || p.endsWith('_test.py') || p.includes('/test_');
2695
- }).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
2696
3111
 
2697
3112
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2698
3113
 
@@ -3049,6 +3464,17 @@ async function processRepoScanJob(jobId: string, config: any) {
3049
3464
  } catch (e: any) {
3050
3465
  logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
3051
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
+ }
3052
3478
 
3053
3479
  await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
3054
3480
  await withTenant(tenantId, (tx) =>
@@ -3217,7 +3643,7 @@ async function processCreatePRJob(jobId: string, config: any) {
3217
3643
  if (testScriptIds && testScriptIds.length > 0) {
3218
3644
  scripts = await withTenant(tenantId, (tx) =>
3219
3645
  (tx as any).testScript.findMany({
3220
- where: { id: { in: testScriptIds }, tenantId },
3646
+ where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
3221
3647
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
3222
3648
  })
3223
3649
  );
@@ -3225,7 +3651,7 @@ async function processCreatePRJob(jobId: string, config: any) {
3225
3651
  // All scripts that haven't been pushed yet
3226
3652
  scripts = await withTenant(tenantId, (tx) =>
3227
3653
  (tx as any).testScript.findMany({
3228
- where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] } },
3654
+ where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] }, code: { not: null } },
3229
3655
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
3230
3656
  })
3231
3657
  );