@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/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,15 +248,22 @@ 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
- for (let i = 0; i < concurrency; i++) {
216
- const job = await claimNextQueuedJob();
217
- if (!job) break;
218
- void dispatchClaimedJob(job);
258
+ const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: "queue.paused" } });
259
+ if (pauseRow?.value === "true") {
260
+ logger.info("[job-worker] queue is paused \u2014 skipping claim");
261
+ } else {
262
+ for (let i = 0; i < concurrency; i++) {
263
+ const job = await claimNextQueuedJob();
264
+ if (!job) break;
265
+ void dispatchClaimedJob(job);
266
+ }
219
267
  }
220
268
  } catch (err) {
221
269
  logger.error({ err: String(err) }, "[job-worker] claim loop failed");
@@ -272,9 +320,9 @@ async function enqueueJob(type, config) {
272
320
  }
273
321
  })
274
322
  );
275
- if (type === "CRAWL" && process.env.JOB_QUEUE_BACKEND === "sqs") {
323
+ if (process.env.JOB_QUEUE_BACKEND === "sqs") {
276
324
  const { sendJobToSqs } = await import("./sqs-queue.js");
277
- await sendJobToSqs(job.id, tenantId, config);
325
+ await sendJobToSqs(job.id);
278
326
  } else if (process.env.DISABLE_WORKER !== "true") {
279
327
  setImmediate(() => claimSpecificJob(job.id).then((claimed) => claimed && dispatchClaimedJob(claimed)));
280
328
  }
@@ -656,8 +704,8 @@ async function processJob(jobId, config, opts) {
656
704
  WHERE tc."tenantId" = ${tenantId}
657
705
  AND s."projectId" = ${projectId}
658
706
  AND s."elementHashChangedAt" IS NOT NULL
659
- AND tc."genAt" IS NOT NULL
660
- AND s."elementHashChangedAt" > tc."genAt"
707
+ AND tc."createdAt" IS NOT NULL
708
+ AND s."elementHashChangedAt" > tc."createdAt"
661
709
  AND tc.status != 'PENDING_REVIEW'
662
710
  AND tc.status != 'RETIRED'
663
711
  `;
@@ -706,6 +754,7 @@ async function processJob(jobId, config, opts) {
706
754
  status: aborted ? "CANCELLED" : "FAILED",
707
755
  error: aborted ? "Stopped by user" : String(err),
708
756
  finishedAt: /* @__PURE__ */ new Date(),
757
+ ...aborted ? {} : { nextRetryAt: new Date(Date.now() + 6e4) },
709
758
  ...checkpoint ? { result: { checkpoint } } : {}
710
759
  }
711
760
  })
@@ -788,7 +837,7 @@ async function processApiTestsJob(jobId, config) {
788
837
  } catch (err) {
789
838
  await withTenant(
790
839
  tenantId,
791
- (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) } })
792
841
  );
793
842
  }
794
843
  }
@@ -823,7 +872,7 @@ async function processSecurityTestsJob(jobId, config) {
823
872
  } catch (err) {
824
873
  await withTenant(
825
874
  tenantId,
826
- (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) } })
827
876
  );
828
877
  }
829
878
  }
@@ -858,7 +907,7 @@ async function processIntegrationTestsJob(jobId, config) {
858
907
  } catch (err) {
859
908
  await withTenant(
860
909
  tenantId,
861
- (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) } })
862
911
  );
863
912
  }
864
913
  }
@@ -1045,8 +1094,8 @@ async function processNativeCrawlJob(jobId, config) {
1045
1094
  WHERE tc."tenantId" = ${tenantId}
1046
1095
  AND s."projectId" = ${projectId}
1047
1096
  AND s."elementHashChangedAt" IS NOT NULL
1048
- AND tc."genAt" IS NOT NULL
1049
- AND s."elementHashChangedAt" > tc."genAt"
1097
+ AND tc."createdAt" IS NOT NULL
1098
+ AND s."elementHashChangedAt" > tc."createdAt"
1050
1099
  AND tc.status != 'PENDING_REVIEW'
1051
1100
  AND tc.status != 'RETIRED'
1052
1101
  `;
@@ -1492,43 +1541,48 @@ async function processDiscoverJob(jobId, config, opts) {
1492
1541
  password: crawlCfg.password ?? "",
1493
1542
  ...crawlCfg.totpSecret ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret) } : {}
1494
1543
  } : void 0;
1495
- const authResult = await crawlProject({
1496
- tenantId,
1497
- projectId,
1498
- appUrl,
1499
- discoveryMode: true,
1500
- credentials: runtimeCreds,
1501
- storageState: crawlCfg.storageState,
1502
- httpBasicUsername: crawlCfg.httpBasicUsername,
1503
- httpBasicPassword: crawlCfg.httpBasicPassword,
1504
- authToken: crawlCfg.authToken,
1505
- authTokenHeader: crawlCfg.authTokenHeader,
1506
- authTokenPrefix: crawlCfg.authTokenPrefix,
1507
- startUrls: crawlCfg.postLoginUrl ? [crawlCfg.postLoginUrl, ...sitemapUrls.length > 0 ? sitemapUrls : []] : sitemapUrls.length > 0 ? sitemapUrls : void 0,
1508
- screenshotDir,
1509
- maxScreens: maxDiscoverUrls,
1510
- proxyUrl,
1511
- chromeProfilePath: crawlCfg.chromeProfilePath,
1512
- useFirefox: crawlCfg.useFirefox,
1513
- storageConfig: discoverStorageConfig,
1514
- captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1515
- captchaSolverProvider: crawlCfg.captchaSolverProvider,
1516
- mailslurpApiKey: crawlCfg.mailslurpApiKey,
1517
- mailslurpInboxId: crawlCfg.mailslurpInboxId,
1518
- loginInstructions: crawlCfg.loginInstructions,
1519
- ...resolvedSso,
1520
- abortSignal: controller.signal,
1521
- jobId,
1522
- onUrlsDiscovered: async (urls) => {
1523
- for (const u of urls) allUrls.add(u);
1524
- const pct = 52 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 38);
1525
- await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)\u2026`);
1526
- }
1527
- }, async (pct, step) => {
1528
- await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
1529
- }).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) => {
1530
1583
  if (err?.message === "CRAWL_ABORTED") throw err;
1531
- 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");
1532
1586
  return { discoveredUrls: [] };
1533
1587
  });
1534
1588
  for (const u of authResult.discoveredUrls ?? []) {
@@ -1947,9 +2001,20 @@ async function processCaptureStepsJob(jobId, config) {
1947
2001
  ]);
1948
2002
  if (!tc) throw new Error("Test case not found");
1949
2003
  const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
1950
- const stepTexts = rawSteps.map(captureStepText).filter(Boolean);
2004
+ if (rawSteps.length === 0) throw new Error("Test case has no steps to capture");
2005
+ const _tdi = tc.testDataInline ?? {};
2006
+ const _tdiStartUrl = typeof _tdi["startUrl"] === "string" && _tdi["startUrl"].startsWith("http") ? _tdi["startUrl"] : "";
2007
+ const _recordedUrls = Array.isArray(_tdi["recordedUrls"]) ? _tdi["recordedUrls"].map(String) : [];
2008
+ const _recordedStartUrl = _recordedUrls.find((u) => u.startsWith("http")) ?? "";
2009
+ const baseUrl = config.baseUrl || _tdiStartUrl || _recordedStartUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || "";
2010
+ const _envTokenMap = {};
2011
+ for (const [k, v] of Object.entries(_tdi)) {
2012
+ if (typeof v === "string" && v.startsWith("http")) _envTokenMap[k] = v;
2013
+ }
2014
+ if (_recordedStartUrl && !_envTokenMap["startUrl"]) _envTokenMap["startUrl"] = _recordedStartUrl;
2015
+ const resolveStepText = (text) => text.replace(/\{\{\s*fze\.env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`).replace(/\[env:([A-Za-z_][A-Za-z0-9_]*)\]/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`);
2016
+ const stepTexts = rawSteps.map(captureStepText).filter(Boolean).map(resolveStepText);
1951
2017
  if (stepTexts.length === 0) throw new Error("Test case has no steps to capture");
1952
- const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || "";
1953
2018
  let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
1954
2019
  plan = Array.isArray(plan) ? plan.filter(Boolean).map((step, idx) => ({ ...step, stepNumber: idx + 1 })) : [];
1955
2020
  if (plan.length === 0) plan = fallbackExecutionPlan(rawSteps);
@@ -2106,6 +2171,283 @@ async function processCaptureStepsJob(jobId, config) {
2106
2171
  });
2107
2172
  }
2108
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
+ }
2109
2451
  jobDispatchTable = {
2110
2452
  CRAWL: processJob,
2111
2453
  CAPTURE_STEPS: processCaptureStepsJob,
@@ -2125,7 +2467,10 @@ jobDispatchTable = {
2125
2467
  BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
2126
2468
  CREATE_PR: processCreatePRJob,
2127
2469
  NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
2128
- // 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.
2129
2474
  // If the worker claims one, the API crashed after creating it — mark failed so it's visible.
2130
2475
  GENERATE_TESTS: async (jobId, _config) => {
2131
2476
  logger.error({ jobId }, "[job-worker] GENERATE_TESTS claimed by worker \u2014 API did not process it in-process");
@@ -2143,14 +2488,6 @@ jobDispatchTable = {
2143
2488
  }).catch(() => {
2144
2489
  });
2145
2490
  },
2146
- GENERATE_SCRIPT: async (jobId, _config) => {
2147
- logger.error({ jobId }, "[job-worker] GENERATE_SCRIPT claimed by worker \u2014 API did not process it in-process");
2148
- await prismaAdmin.platformJob.update({
2149
- where: { id: jobId },
2150
- data: { status: "FAILED", error: "Script generation was not completed by the API \u2014 please retry from the UI.", finishedAt: /* @__PURE__ */ new Date() }
2151
- }).catch(() => {
2152
- });
2153
- },
2154
2491
  GENERATE_TEST_PLAN: async (jobId, _config) => {
2155
2492
  logger.error({ jobId }, "[job-worker] GENERATE_TEST_PLAN claimed by worker \u2014 API did not process it in-process");
2156
2493
  await prismaAdmin.platformJob.update({
@@ -2189,6 +2526,13 @@ async function processRepoScanJob(jobId, config) {
2189
2526
  const treeResult = await fetchRepoTree(token, conn.repoFullName, "HEAD");
2190
2527
  if (treeResult.truncated) {
2191
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
+ );
2192
2536
  }
2193
2537
  const frameworks = detectFramework(treeResult.paths);
2194
2538
  const primary = frameworks[0];
@@ -2200,12 +2544,17 @@ async function processRepoScanJob(jobId, config) {
2200
2544
  const { minimatch } = await import("minimatch").catch(() => ({ minimatch: null }));
2201
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/"));
2202
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
+ ])) : [];
2203
2552
  const testPaths = treeResult.paths.filter((p) => {
2204
2553
  if (language === "java") return isJavaTestFile(p);
2205
2554
  if (language === "python") return isPythonTestFile(p);
2206
- if (minimatch) return minimatch(p, testGlob, { matchBase: true });
2207
- return p.endsWith(".spec.ts") || p.endsWith(".spec.js") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.py") || p.includes("/test_");
2208
- }).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);
2209
2558
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2210
2559
  if (testPaths.length === 0) {
2211
2560
  logger.warn({ jobId, repoConnectionId, framework }, "[repo-scan] no test files matched glob \u2014 scan complete with 0 results");
@@ -2576,6 +2925,17 @@ async function processRepoScanJob(jobId, config) {
2576
2925
  } catch (e) {
2577
2926
  logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] repo skill files generation failed (non-fatal)");
2578
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
+ }
2579
2939
  await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
2580
2940
  await withTenant(
2581
2941
  tenantId,
@@ -2731,7 +3091,7 @@ async function processCreatePRJob(jobId, config) {
2731
3091
  scripts = await withTenant(
2732
3092
  tenantId,
2733
3093
  (tx) => tx.testScript.findMany({
2734
- where: { id: { in: testScriptIds }, tenantId },
3094
+ where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
2735
3095
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
2736
3096
  })
2737
3097
  );
@@ -2739,7 +3099,7 @@ async function processCreatePRJob(jobId, config) {
2739
3099
  scripts = await withTenant(
2740
3100
  tenantId,
2741
3101
  (tx) => tx.testScript.findMany({
2742
- where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ["PASS", "PENDING"] } },
3102
+ where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ["PASS", "PENDING"] }, code: { not: null } },
2743
3103
  include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
2744
3104
  })
2745
3105
  );
@@ -2925,6 +3285,7 @@ export {
2925
3285
  claimNextQueuedJob,
2926
3286
  claimSpecificJob,
2927
3287
  createTrackedJob,
3288
+ dispatchClaimedJob,
2928
3289
  enqueueJob,
2929
3290
  finishTrackedJob,
2930
3291
  generateTOTP,
@@ -2932,10 +3293,12 @@ export {
2932
3293
  listJobs,
2933
3294
  processDataRetentionPurge,
2934
3295
  processJob,
3296
+ promoteToDeadLetterQueue,
2935
3297
  requeueOrphanedJobs,
2936
3298
  requeueStaleRunningJobs,
2937
3299
  rerunJob,
2938
3300
  resumeJob,
3301
+ retryEligibleFailedJobs,
2939
3302
  startJobWorkerLoop,
2940
3303
  stopJobWorkerLoop,
2941
3304
  updateJobProgress