@detiq/crawler 1.1.23 → 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 +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/dist/job-queue.js CHANGED
@@ -194,12 +194,53 @@ async function dispatchClaimedJob(job) {
194
194
  logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, "[job-worker] job threw uncaught error");
195
195
  await prismaAdmin.platformJob.update({
196
196
  where: { id: job.id },
197
- data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() }
197
+ data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) }
198
198
  }).catch(() => {
199
199
  });
200
200
  }
201
201
  }
202
202
  let workerLoopTimer = null;
203
+ async function retryEligibleFailedJobs() {
204
+ const rows = await prismaAdmin.$queryRaw`
205
+ UPDATE "PlatformJob"
206
+ SET status = 'QUEUED',
207
+ "retryCount" = "retryCount" + 1,
208
+ "nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
209
+ "workerId" = NULL
210
+ WHERE status = 'FAILED'
211
+ AND dlq = false
212
+ AND "nextRetryAt" <= NOW()
213
+ AND "retryCount" < CASE
214
+ WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
215
+ WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
216
+ WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
217
+ ELSE 1
218
+ END
219
+ RETURNING id
220
+ `;
221
+ if (rows.length > 0) {
222
+ logger.info({ jobIds: rows.map((r) => r.id) }, "[job-worker] requeued failed job(s) for retry");
223
+ }
224
+ }
225
+ async function promoteToDeadLetterQueue() {
226
+ const rows = await prismaAdmin.$queryRaw`
227
+ UPDATE "PlatformJob"
228
+ SET dlq = true,
229
+ "dlqReason" = error
230
+ WHERE status = 'FAILED'
231
+ AND dlq = false
232
+ AND "retryCount" >= CASE
233
+ WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
234
+ WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
235
+ WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
236
+ ELSE 1
237
+ END
238
+ RETURNING id
239
+ `;
240
+ if (rows.length > 0) {
241
+ logger.error({ jobIds: rows.map((r) => r.id) }, "[job-worker] promoted failed job(s) to DLQ");
242
+ }
243
+ }
203
244
  function startJobWorkerLoop(opts) {
204
245
  if (workerLoopTimer) return;
205
246
  const pollIntervalMs = opts?.pollIntervalMs ?? WORKER_POLL_INTERVAL_MS;
@@ -207,9 +248,11 @@ function startJobWorkerLoop(opts) {
207
248
  logger.info({ workerId: WORKER_ID, pollIntervalMs, concurrency }, "[job-worker] starting");
208
249
  const tick = async () => {
209
250
  try {
251
+ await retryEligibleFailedJobs();
252
+ await promoteToDeadLetterQueue();
210
253
  await requeueStaleRunningJobs();
211
254
  } catch (err) {
212
- logger.error({ err: String(err) }, "[job-worker] stale-job sweep failed");
255
+ logger.error({ err: String(err) }, "[job-worker] stale/retry job sweep failed");
213
256
  }
214
257
  try {
215
258
  const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: "queue.paused" } });
@@ -277,9 +320,9 @@ async function enqueueJob(type, config) {
277
320
  }
278
321
  })
279
322
  );
280
- if (type === "CRAWL" && process.env.JOB_QUEUE_BACKEND === "sqs") {
323
+ if (process.env.JOB_QUEUE_BACKEND === "sqs") {
281
324
  const { sendJobToSqs } = await import("./sqs-queue.js");
282
- await sendJobToSqs(job.id, tenantId, config);
325
+ await sendJobToSqs(job.id);
283
326
  } else if (process.env.DISABLE_WORKER !== "true") {
284
327
  setImmediate(() => claimSpecificJob(job.id).then((claimed) => claimed && dispatchClaimedJob(claimed)));
285
328
  }
@@ -661,8 +704,8 @@ async function processJob(jobId, config, opts) {
661
704
  WHERE tc."tenantId" = ${tenantId}
662
705
  AND s."projectId" = ${projectId}
663
706
  AND s."elementHashChangedAt" IS NOT NULL
664
- AND tc."genAt" IS NOT NULL
665
- AND s."elementHashChangedAt" > tc."genAt"
707
+ AND tc."createdAt" IS NOT NULL
708
+ AND s."elementHashChangedAt" > tc."createdAt"
666
709
  AND tc.status != 'PENDING_REVIEW'
667
710
  AND tc.status != 'RETIRED'
668
711
  `;
@@ -711,6 +754,7 @@ async function processJob(jobId, config, opts) {
711
754
  status: aborted ? "CANCELLED" : "FAILED",
712
755
  error: aborted ? "Stopped by user" : String(err),
713
756
  finishedAt: /* @__PURE__ */ new Date(),
757
+ ...aborted ? {} : { nextRetryAt: new Date(Date.now() + 6e4) },
714
758
  ...checkpoint ? { result: { checkpoint } } : {}
715
759
  }
716
760
  })
@@ -793,7 +837,7 @@ async function processApiTestsJob(jobId, config) {
793
837
  } catch (err) {
794
838
  await withTenant(
795
839
  tenantId,
796
- (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date() } })
840
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) } })
797
841
  );
798
842
  }
799
843
  }
@@ -828,7 +872,7 @@ async function processSecurityTestsJob(jobId, config) {
828
872
  } catch (err) {
829
873
  await withTenant(
830
874
  tenantId,
831
- (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date() } })
875
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) } })
832
876
  );
833
877
  }
834
878
  }
@@ -863,7 +907,7 @@ async function processIntegrationTestsJob(jobId, config) {
863
907
  } catch (err) {
864
908
  await withTenant(
865
909
  tenantId,
866
- (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date() } })
910
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) } })
867
911
  );
868
912
  }
869
913
  }
@@ -1050,8 +1094,8 @@ async function processNativeCrawlJob(jobId, config) {
1050
1094
  WHERE tc."tenantId" = ${tenantId}
1051
1095
  AND s."projectId" = ${projectId}
1052
1096
  AND s."elementHashChangedAt" IS NOT NULL
1053
- AND tc."genAt" IS NOT NULL
1054
- AND s."elementHashChangedAt" > tc."genAt"
1097
+ AND tc."createdAt" IS NOT NULL
1098
+ AND s."elementHashChangedAt" > tc."createdAt"
1055
1099
  AND tc.status != 'PENDING_REVIEW'
1056
1100
  AND tc.status != 'RETIRED'
1057
1101
  `;
@@ -1497,43 +1541,48 @@ async function processDiscoverJob(jobId, config, opts) {
1497
1541
  password: crawlCfg.password ?? "",
1498
1542
  ...crawlCfg.totpSecret ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret) } : {}
1499
1543
  } : void 0;
1500
- const authResult = await crawlProject({
1501
- tenantId,
1502
- projectId,
1503
- appUrl,
1504
- discoveryMode: true,
1505
- credentials: runtimeCreds,
1506
- storageState: crawlCfg.storageState,
1507
- httpBasicUsername: crawlCfg.httpBasicUsername,
1508
- httpBasicPassword: crawlCfg.httpBasicPassword,
1509
- authToken: crawlCfg.authToken,
1510
- authTokenHeader: crawlCfg.authTokenHeader,
1511
- authTokenPrefix: crawlCfg.authTokenPrefix,
1512
- startUrls: crawlCfg.postLoginUrl ? [crawlCfg.postLoginUrl, ...sitemapUrls.length > 0 ? sitemapUrls : []] : sitemapUrls.length > 0 ? sitemapUrls : void 0,
1513
- screenshotDir,
1514
- maxScreens: maxDiscoverUrls,
1515
- proxyUrl,
1516
- chromeProfilePath: crawlCfg.chromeProfilePath,
1517
- useFirefox: crawlCfg.useFirefox,
1518
- storageConfig: discoverStorageConfig,
1519
- captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1520
- captchaSolverProvider: crawlCfg.captchaSolverProvider,
1521
- mailslurpApiKey: crawlCfg.mailslurpApiKey,
1522
- mailslurpInboxId: crawlCfg.mailslurpInboxId,
1523
- loginInstructions: crawlCfg.loginInstructions,
1524
- ...resolvedSso,
1525
- abortSignal: controller.signal,
1526
- jobId,
1527
- onUrlsDiscovered: async (urls) => {
1528
- for (const u of urls) allUrls.add(u);
1529
- const pct = 52 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 38);
1530
- await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)\u2026`);
1531
- }
1532
- }, async (pct, step) => {
1533
- await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
1534
- }).catch((err) => {
1544
+ const PHASE3_TIMEOUT_MS = 20 * 6e4;
1545
+ const authResult = await Promise.race([
1546
+ crawlProject({
1547
+ tenantId,
1548
+ projectId,
1549
+ appUrl,
1550
+ discoveryMode: true,
1551
+ credentials: runtimeCreds,
1552
+ storageState: crawlCfg.storageState,
1553
+ httpBasicUsername: crawlCfg.httpBasicUsername,
1554
+ httpBasicPassword: crawlCfg.httpBasicPassword,
1555
+ authToken: crawlCfg.authToken,
1556
+ authTokenHeader: crawlCfg.authTokenHeader,
1557
+ authTokenPrefix: crawlCfg.authTokenPrefix,
1558
+ startUrls: crawlCfg.postLoginUrl ? [crawlCfg.postLoginUrl, ...sitemapUrls.length > 0 ? sitemapUrls : []] : sitemapUrls.length > 0 ? sitemapUrls : void 0,
1559
+ screenshotDir,
1560
+ maxScreens: maxDiscoverUrls,
1561
+ proxyUrl,
1562
+ chromeProfilePath: crawlCfg.chromeProfilePath,
1563
+ useFirefox: crawlCfg.useFirefox,
1564
+ storageConfig: discoverStorageConfig,
1565
+ captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1566
+ captchaSolverProvider: crawlCfg.captchaSolverProvider,
1567
+ mailslurpApiKey: crawlCfg.mailslurpApiKey,
1568
+ mailslurpInboxId: crawlCfg.mailslurpInboxId,
1569
+ loginInstructions: crawlCfg.loginInstructions,
1570
+ ...resolvedSso,
1571
+ abortSignal: controller.signal,
1572
+ jobId,
1573
+ onUrlsDiscovered: async (urls) => {
1574
+ for (const u of urls) allUrls.add(u);
1575
+ const pct = 52 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 38);
1576
+ await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)\u2026`);
1577
+ }
1578
+ }, async (pct, step) => {
1579
+ await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
1580
+ }),
1581
+ new Promise((_, rej) => setTimeout(() => rej(new Error("AUTH_PHASE_TIMEOUT")), PHASE3_TIMEOUT_MS))
1582
+ ]).catch((err) => {
1535
1583
  if (err?.message === "CRAWL_ABORTED") throw err;
1536
- logger.warn({ err: err?.message }, "[discover] Phase 3 authenticated spider failed \u2014 using unauthenticated results");
1584
+ const reason = err?.message === "AUTH_PHASE_TIMEOUT" ? "Authenticated spider timed out after 20 minutes" : err?.message;
1585
+ logger.warn({ reason }, "[discover] Phase 3 authenticated spider failed \u2014 using unauthenticated results");
1537
1586
  return { discoveredUrls: [] };
1538
1587
  });
1539
1588
  for (const u of authResult.discoveredUrls ?? []) {
@@ -2122,6 +2171,283 @@ async function processCaptureStepsJob(jobId, config) {
2122
2171
  });
2123
2172
  }
2124
2173
  }
2174
+ async function processMultiRoleCrawlJob(jobId, config) {
2175
+ const { tenantId, projectId } = config;
2176
+ try {
2177
+ await withTenant(
2178
+ tenantId,
2179
+ (tx) => tx.platformJob.updateMany({
2180
+ where: { id: jobId, status: { not: "CANCELLED" } },
2181
+ data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date(), workerId: WORKER_ID }
2182
+ })
2183
+ );
2184
+ await updateJobProgress(tenantId, jobId, 5, "Loading project crawl config\u2026");
2185
+ const proj = await withTenant(
2186
+ tenantId,
2187
+ (tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
2188
+ );
2189
+ const savedCrawlConfig = proj?.crawlConfig ?? {};
2190
+ const credentialProfiles = config.credentialProfiles ?? [];
2191
+ await updateJobProgress(tenantId, jobId, 10, "Enqueuing primary crawl\u2026");
2192
+ const primaryJobId = await enqueueJob("CRAWL", {
2193
+ tenantId,
2194
+ projectId,
2195
+ appUrl: config.appUrl || savedCrawlConfig.appUrl,
2196
+ credentials: savedCrawlConfig.username && savedCrawlConfig.password ? { username: savedCrawlConfig.username, password: savedCrawlConfig.password, ...savedCrawlConfig.totpSecret ? { totpSecret: savedCrawlConfig.totpSecret } : {} } : void 0,
2197
+ storageState: savedCrawlConfig.storageState,
2198
+ screenshotDir: platformConfig.screenshotDir,
2199
+ startUrls: savedCrawlConfig.seedUrls?.length ? savedCrawlConfig.seedUrls : void 0,
2200
+ deviceProfiles: savedCrawlConfig.deviceProfiles ?? ["DESKTOP"],
2201
+ loginInstructions: savedCrawlConfig.loginInstructions,
2202
+ proxyUrl: savedCrawlConfig.proxyUrl,
2203
+ useSitemap: true,
2204
+ sitemapUrl: savedCrawlConfig.sitemapUrl
2205
+ });
2206
+ logger.info({ primaryJobId, projectId }, "[multi-role-crawl] primary CRAWL enqueued");
2207
+ const PRIMARY_TIMEOUT_MS = 2 * 60 * 60 * 1e3;
2208
+ const POLL_INTERVAL_MS = 1e4;
2209
+ const deadline = Date.now() + PRIMARY_TIMEOUT_MS;
2210
+ let primaryStatus = "QUEUED";
2211
+ while (Date.now() < deadline) {
2212
+ const primaryJob = await prismaAdmin.platformJob.findUnique({
2213
+ where: { id: primaryJobId },
2214
+ select: { status: true }
2215
+ }).catch(() => null);
2216
+ primaryStatus = primaryJob?.status ?? "UNKNOWN";
2217
+ if (primaryStatus === "DONE" || primaryStatus === "FAILED" || primaryStatus === "CANCELLED") break;
2218
+ await updateJobProgress(tenantId, jobId, 20, `Waiting for primary crawl (${primaryStatus})\u2026`);
2219
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
2220
+ }
2221
+ if (primaryStatus !== "DONE") {
2222
+ throw new Error(`Primary crawl ${primaryJobId} ended with status ${primaryStatus} \u2014 aborting role crawls`);
2223
+ }
2224
+ await updateJobProgress(tenantId, jobId, 50, "Collecting screen URLs for role crawls\u2026");
2225
+ const screens = await prismaAdmin.screen.findMany({
2226
+ where: { tenantId, projectId, status: "ACTIVE" },
2227
+ select: { id: true, url: true }
2228
+ }).catch(() => []);
2229
+ const screenUrls = screens.filter((s) => s.url).map((s) => s.url);
2230
+ logger.info({ screenCount: screenUrls.length, projectId }, "[multi-role-crawl] screen URLs collected");
2231
+ if (credentialProfiles.length === 0) {
2232
+ logger.warn({ projectId, jobId }, "[multi-role-crawl] no credential profiles \u2014 nothing to role-crawl");
2233
+ }
2234
+ const roleJobs = [];
2235
+ for (const profile of credentialProfiles) {
2236
+ if (!profile.username || !profile.password) continue;
2237
+ const roleJobId = await enqueueJob("ROLE_CRAWL", {
2238
+ tenantId,
2239
+ projectId,
2240
+ appUrl: config.appUrl || savedCrawlConfig.appUrl,
2241
+ credentials: { username: profile.username, password: profile.password },
2242
+ roleProfileName: profile.name,
2243
+ parentJobId: jobId,
2244
+ selectiveUrls: config.fullReCrawl ? void 0 : screenUrls.length > 0 ? screenUrls : void 0,
2245
+ startUrls: config.fullReCrawl ? savedCrawlConfig.seedUrls ?? [] : void 0,
2246
+ maxScreens: config.fullReCrawl ? void 0 : screenUrls.length + 10,
2247
+ screenshotDir: platformConfig.screenshotDir,
2248
+ storageState: savedCrawlConfig.storageState,
2249
+ deviceProfiles: savedCrawlConfig.deviceProfiles ?? ["DESKTOP"],
2250
+ loginInstructions: savedCrawlConfig.loginInstructions,
2251
+ proxyUrl: savedCrawlConfig.proxyUrl
2252
+ });
2253
+ roleJobs.push({ roleName: profile.name, jobId: roleJobId });
2254
+ logger.info({ roleJobId, roleName: profile.name, projectId }, "[multi-role-crawl] ROLE_CRAWL enqueued");
2255
+ }
2256
+ const ROLE_TIMEOUT_MS = 2 * 60 * 60 * 1e3;
2257
+ const roleDeadline = Date.now() + ROLE_TIMEOUT_MS;
2258
+ const roleResults = [];
2259
+ while (Date.now() < roleDeadline && roleJobs.length > 0) {
2260
+ const pending = [];
2261
+ for (const rj of roleJobs) {
2262
+ const rJob = await prismaAdmin.platformJob.findUnique({
2263
+ where: { id: rj.jobId },
2264
+ select: { status: true, result: true }
2265
+ }).catch(() => null);
2266
+ const st = rJob?.status ?? "UNKNOWN";
2267
+ if (st === "DONE" || st === "FAILED" || st === "CANCELLED") {
2268
+ roleResults.push({
2269
+ roleName: rj.roleName,
2270
+ jobId: rj.jobId,
2271
+ status: st,
2272
+ screensTagged: rJob?.result?.screensTagged
2273
+ });
2274
+ } else {
2275
+ pending.push(rj);
2276
+ }
2277
+ }
2278
+ roleJobs.length = 0;
2279
+ roleJobs.push(...pending);
2280
+ if (roleJobs.length === 0) break;
2281
+ const pct = Math.round(60 + roleResults.length / (roleResults.length + roleJobs.length) * 35);
2282
+ await updateJobProgress(tenantId, jobId, pct, `Role crawls: ${roleResults.length} done, ${roleJobs.length} pending\u2026`);
2283
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
2284
+ }
2285
+ for (const rj of roleJobs) {
2286
+ roleResults.push({ roleName: rj.roleName, jobId: rj.jobId, status: "TIMEOUT" });
2287
+ }
2288
+ await withTenant(
2289
+ tenantId,
2290
+ (tx) => tx.platformJob.update({
2291
+ where: { id: jobId },
2292
+ data: {
2293
+ status: "DONE",
2294
+ progress: 100,
2295
+ finishedAt: /* @__PURE__ */ new Date(),
2296
+ result: { primaryJobId, roleJobs: roleResults }
2297
+ }
2298
+ })
2299
+ );
2300
+ logger.info({ jobId, primaryJobId, roleCount: roleResults.length, projectId }, "[multi-role-crawl] completed");
2301
+ } catch (err) {
2302
+ const msg = String(err?.message ?? err);
2303
+ logger.error({ jobId, err: msg }, "[multi-role-crawl] failed");
2304
+ await withTenant(
2305
+ tenantId,
2306
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: msg, finishedAt: /* @__PURE__ */ new Date() } })
2307
+ ).catch(() => {
2308
+ });
2309
+ }
2310
+ }
2311
+ async function processRoleCrawlJob(jobId, config) {
2312
+ const { tenantId, projectId } = config;
2313
+ try {
2314
+ const claimed = await withTenant(
2315
+ tenantId,
2316
+ (tx) => tx.platformJob.updateMany({
2317
+ where: { id: jobId, status: { not: "CANCELLED" } },
2318
+ data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date(), workerId: WORKER_ID }
2319
+ })
2320
+ );
2321
+ if (claimed.count === 0) return;
2322
+ const controller = new AbortController();
2323
+ runningControllers.set(jobId, controller);
2324
+ const heartbeatTimer = setInterval(() => void heartbeatJob(tenantId, jobId), JOB_HEARTBEAT_INTERVAL_MS);
2325
+ const onProgress = async (pct, step) => {
2326
+ await updateJobProgress(tenantId, jobId, pct, step);
2327
+ };
2328
+ try {
2329
+ const proj = await withTenant(
2330
+ tenantId,
2331
+ (tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
2332
+ );
2333
+ const savedCrawlConfig = proj?.crawlConfig ?? {};
2334
+ const runtimeCreds = config.credentials ? {
2335
+ ...config.credentials,
2336
+ ...config.credentials.totpSecret ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) } : {}
2337
+ } : void 0;
2338
+ const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
2339
+ const result = await crawlProject({
2340
+ ...config,
2341
+ credentials: runtimeCreds,
2342
+ storageState: config.storageState ?? savedCrawlConfig.storageState,
2343
+ proxyUrl: config.proxyUrl ?? savedCrawlConfig.proxyUrl,
2344
+ loginInstructions: config.loginInstructions ?? savedCrawlConfig.loginInstructions,
2345
+ abortSignal: controller.signal,
2346
+ jobId,
2347
+ storageConfig: storageConfigForCrawl
2348
+ }, onProgress);
2349
+ clearInterval(heartbeatTimer);
2350
+ runningControllers.delete(jobId);
2351
+ const roleName = config.roleProfileName;
2352
+ const discovered = result.discovered ?? [];
2353
+ const roleScreenIds = discovered.filter((d) => d.screenId).map((d) => d.screenId);
2354
+ if (roleScreenIds.length > 0 && roleName) {
2355
+ await prismaAdmin.$executeRaw`
2356
+ UPDATE "Screen"
2357
+ SET "roleAccess" = array(SELECT DISTINCT unnest(array_append("roleAccess", ${roleName}::text)))
2358
+ WHERE id = ANY(${roleScreenIds}::text[])
2359
+ AND "tenantId" = ${tenantId}
2360
+ `.catch((e) => logger.warn({ err: e?.message, roleName, count: roleScreenIds.length }, "[role-crawl] roleAccess tag failed (non-fatal)"));
2361
+ }
2362
+ await withTenant(
2363
+ tenantId,
2364
+ (tx) => tx.platformJob.update({
2365
+ where: { id: jobId },
2366
+ data: {
2367
+ status: "DONE",
2368
+ progress: 100,
2369
+ finishedAt: /* @__PURE__ */ new Date(),
2370
+ result: { screensTagged: roleScreenIds.length, roleName }
2371
+ }
2372
+ })
2373
+ );
2374
+ logger.info({ jobId, roleName, screensTagged: roleScreenIds.length, projectId }, "[role-crawl] completed");
2375
+ } catch (err) {
2376
+ clearInterval(heartbeatTimer);
2377
+ runningControllers.delete(jobId);
2378
+ const aborted = String(err).includes("CRAWL_ABORTED");
2379
+ await withTenant(
2380
+ tenantId,
2381
+ (tx) => tx.platformJob.update({
2382
+ where: { id: jobId },
2383
+ data: {
2384
+ status: aborted ? "CANCELLED" : "FAILED",
2385
+ error: aborted ? "Stopped by user" : String(err),
2386
+ finishedAt: /* @__PURE__ */ new Date()
2387
+ }
2388
+ })
2389
+ );
2390
+ } finally {
2391
+ runningControllers.delete(jobId);
2392
+ }
2393
+ } catch (err) {
2394
+ const msg = String(err?.message ?? err);
2395
+ logger.error({ jobId, err: msg }, "[role-crawl] outer failure");
2396
+ await withTenant(
2397
+ tenantId,
2398
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: msg, finishedAt: /* @__PURE__ */ new Date() } })
2399
+ ).catch(() => {
2400
+ });
2401
+ }
2402
+ }
2403
+ async function processGenerateScriptJob(jobId, config) {
2404
+ const { tenantId, projectId, language } = config;
2405
+ try {
2406
+ await updateJobProgress(tenantId, jobId, 1, "Loading test cases\u2026").catch(() => {
2407
+ });
2408
+ if (config.packId) {
2409
+ const { generatePackScript } = await import("@detiq/agents");
2410
+ const result = await generatePackScript({ tenantId, projectId, packType: config.packId, language });
2411
+ await withTenant(
2412
+ tenantId,
2413
+ (tx) => tx.platformJob.update({
2414
+ where: { id: jobId },
2415
+ data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result }
2416
+ })
2417
+ );
2418
+ } else {
2419
+ const { generateSuiteScript } = await import("@detiq/agents");
2420
+ let progressChain = Promise.resolve();
2421
+ const result = await generateSuiteScript({
2422
+ tenantId,
2423
+ projectId,
2424
+ suiteId: config.suiteId,
2425
+ language,
2426
+ caseIds: config.caseIds ?? void 0,
2427
+ useV2: config.useV2,
2428
+ styleProfile: config.repoStyleProfile ?? void 0,
2429
+ pageObjectInventory: config.repoPageObjects ?? void 0,
2430
+ jobId,
2431
+ onProgress: (done, total) => {
2432
+ progressChain = progressChain.then(() => updateJobProgress(tenantId, jobId, Math.floor(done / total * 95), `Generated ${done}/${total} scripts`)).catch(() => {
2433
+ });
2434
+ }
2435
+ });
2436
+ await progressChain.catch(() => {
2437
+ });
2438
+ await import("@detiq/agents").then(({ refreshScriptInventory }) => refreshScriptInventory(tenantId, projectId)).catch((e) => logger.warn({ err: e?.message }, "[generate-script] refreshScriptInventory failed"));
2439
+ await withTenant(
2440
+ tenantId,
2441
+ (tx) => tx.platformJob.update({
2442
+ where: { id: jobId },
2443
+ data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result }
2444
+ })
2445
+ );
2446
+ }
2447
+ } catch (e) {
2448
+ await finishTrackedJob(tenantId, jobId, e?.message ?? "generate-script failed");
2449
+ }
2450
+ }
2125
2451
  jobDispatchTable = {
2126
2452
  CRAWL: processJob,
2127
2453
  CAPTURE_STEPS: processCaptureStepsJob,
@@ -2141,7 +2467,10 @@ jobDispatchTable = {
2141
2467
  BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
2142
2468
  CREATE_PR: processCreatePRJob,
2143
2469
  NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
2144
- // The following job types are processed in-process by the API (never QUEUED).
2470
+ MULTI_ROLE_CRAWL: processMultiRoleCrawlJob,
2471
+ ROLE_CRAWL: processRoleCrawlJob,
2472
+ GENERATE_SCRIPT: processGenerateScriptJob,
2473
+ // The following job types are still processed in-process by the API.
2145
2474
  // If the worker claims one, the API crashed after creating it — mark failed so it's visible.
2146
2475
  GENERATE_TESTS: async (jobId, _config) => {
2147
2476
  logger.error({ jobId }, "[job-worker] GENERATE_TESTS claimed by worker \u2014 API did not process it in-process");
@@ -2159,14 +2488,6 @@ jobDispatchTable = {
2159
2488
  }).catch(() => {
2160
2489
  });
2161
2490
  },
2162
- GENERATE_SCRIPT: async (jobId, _config) => {
2163
- logger.error({ jobId }, "[job-worker] GENERATE_SCRIPT claimed by worker \u2014 API did not process it in-process");
2164
- await prismaAdmin.platformJob.update({
2165
- where: { id: jobId },
2166
- data: { status: "FAILED", error: "Script generation was not completed by the API \u2014 please retry from the UI.", finishedAt: /* @__PURE__ */ new Date() }
2167
- }).catch(() => {
2168
- });
2169
- },
2170
2491
  GENERATE_TEST_PLAN: async (jobId, _config) => {
2171
2492
  logger.error({ jobId }, "[job-worker] GENERATE_TEST_PLAN claimed by worker \u2014 API did not process it in-process");
2172
2493
  await prismaAdmin.platformJob.update({
@@ -2205,6 +2526,13 @@ async function processRepoScanJob(jobId, config) {
2205
2526
  const treeResult = await fetchRepoTree(token, conn.repoFullName, "HEAD");
2206
2527
  if (treeResult.truncated) {
2207
2528
  console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) \u2014 detection may be incomplete`);
2529
+ await withTenant(
2530
+ tenantId,
2531
+ (tx) => tx.gitHubRepoConnection.update({
2532
+ where: { id: repoConnectionId },
2533
+ data: { scanError: "Repository tree was truncated by GitHub (>100k files) \u2014 some test files in deep subdirectories may be missing from this scan." }
2534
+ })
2535
+ );
2208
2536
  }
2209
2537
  const frameworks = detectFramework(treeResult.paths);
2210
2538
  const primary = frameworks[0];
@@ -2216,12 +2544,17 @@ async function processRepoScanJob(jobId, config) {
2216
2544
  const { minimatch } = await import("minimatch").catch(() => ({ minimatch: null }));
2217
2545
  const isPythonTestFile = (p) => p.endsWith(".py") && !p.endsWith("__init__.py") && !p.endsWith("conftest.py") && (p.endsWith("_test.py") || /\/test_[^/]+\.py$/.test(p) || /^test_[^/]+\.py$/.test(p) || p.includes("/tests/") || p.includes("/test/"));
2218
2546
  const isJavaTestFile = (p) => p.endsWith(".java") && (/[A-Z]\w*Tests?\.java$/.test(p) || /\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split("/").pop() ?? ""));
2547
+ const testGlobs = minimatch ? Array.from(/* @__PURE__ */ new Set([
2548
+ testGlob,
2549
+ ...testGlob === "**/*.spec.ts" ? ["**/*.test.ts", "**/*.cy.ts"] : [],
2550
+ ...testGlob === "**/*.spec.js" ? ["**/*.test.js", "**/*.cy.js"] : []
2551
+ ])) : [];
2219
2552
  const testPaths = treeResult.paths.filter((p) => {
2220
2553
  if (language === "java") return isJavaTestFile(p);
2221
2554
  if (language === "python") return isPythonTestFile(p);
2222
- if (minimatch) return minimatch(p, testGlob, { matchBase: true });
2223
- return p.endsWith(".spec.ts") || p.endsWith(".spec.js") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.py") || p.includes("/test_");
2224
- }).slice(0, 500);
2555
+ if (testGlobs.length) return testGlobs.some((g) => minimatch(p, g, { matchBase: true }));
2556
+ 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_");
2557
+ }).slice(0, 2e3);
2225
2558
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2226
2559
  if (testPaths.length === 0) {
2227
2560
  logger.warn({ jobId, repoConnectionId, framework }, "[repo-scan] no test files matched glob \u2014 scan complete with 0 results");
@@ -2592,6 +2925,17 @@ async function processRepoScanJob(jobId, config) {
2592
2925
  } catch (e) {
2593
2926
  logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] repo skill files generation failed (non-fatal)");
2594
2927
  }
2928
+ try {
2929
+ const { alignSkillFile } = await import("@detiq/agents");
2930
+ await alignSkillFile(tenantId, projectId, {
2931
+ framework,
2932
+ language,
2933
+ styleProfile: resolvedStyleProfile,
2934
+ repoFullName: conn.repoFullName
2935
+ });
2936
+ } catch (e) {
2937
+ logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] alignSkillFile failed (non-fatal)");
2938
+ }
2595
2939
  await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
2596
2940
  await withTenant(
2597
2941
  tenantId,
@@ -2747,7 +3091,7 @@ async function processCreatePRJob(jobId, config) {
2747
3091
  scripts = await withTenant(
2748
3092
  tenantId,
2749
3093
  (tx) => tx.testScript.findMany({
2750
- where: { id: { in: testScriptIds }, tenantId },
3094
+ where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
2751
3095
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
2752
3096
  })
2753
3097
  );
@@ -2755,7 +3099,7 @@ async function processCreatePRJob(jobId, config) {
2755
3099
  scripts = await withTenant(
2756
3100
  tenantId,
2757
3101
  (tx) => tx.testScript.findMany({
2758
- where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ["PASS", "PENDING"] } },
3102
+ where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ["PASS", "PENDING"] }, code: { not: null } },
2759
3103
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
2760
3104
  })
2761
3105
  );
@@ -2941,6 +3285,7 @@ export {
2941
3285
  claimNextQueuedJob,
2942
3286
  claimSpecificJob,
2943
3287
  createTrackedJob,
3288
+ dispatchClaimedJob,
2944
3289
  enqueueJob,
2945
3290
  finishTrackedJob,
2946
3291
  generateTOTP,
@@ -2948,10 +3293,12 @@ export {
2948
3293
  listJobs,
2949
3294
  processDataRetentionPurge,
2950
3295
  processJob,
3296
+ promoteToDeadLetterQueue,
2951
3297
  requeueOrphanedJobs,
2952
3298
  requeueStaleRunningJobs,
2953
3299
  rerunJob,
2954
3300
  resumeJob,
3301
+ retryEligibleFailedJobs,
2955
3302
  startJobWorkerLoop,
2956
3303
  stopJobWorkerLoop,
2957
3304
  updateJobProgress