@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.
- package/dist/crawler.js +173 -28
- package/dist/crawler.js.map +2 -2
- package/dist/extractors/branding-extractor.js +104 -0
- package/dist/extractors/branding-extractor.js.map +7 -0
- package/dist/extractors/summary-extractor.js +36 -0
- package/dist/extractors/summary-extractor.js.map +7 -0
- package/dist/frontier.js +20 -2
- package/dist/frontier.js.map +2 -2
- package/dist/incremental-diff.js +76 -0
- package/dist/incremental-diff.js.map +2 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +2 -2
- package/dist/interactive-probe.js +108 -17
- package/dist/interactive-probe.js.map +2 -2
- package/dist/job-queue.js +408 -61
- package/dist/job-queue.js.map +2 -2
- package/dist/sqs-queue.js +78 -10
- package/dist/sqs-queue.js.map +2 -2
- package/dist/worker.js +7 -7
- package/dist/worker.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +208 -30
- package/src/extractors/branding-extractor.ts +159 -0
- package/src/extractors/summary-extractor.ts +49 -0
- package/src/frontier.ts +31 -1
- package/src/incremental-diff.ts +138 -0
- package/src/index.ts +1 -1
- package/src/interactive-probe.ts +129 -20
- package/src/job-queue.ts +489 -63
- package/src/sqs-queue.ts +92 -12
- package/src/worker.ts +13 -11
- package/types/crawler.d.ts +20 -0
- package/types/extractors/branding-extractor.d.ts +46 -0
- package/types/extractors/summary-extractor.d.ts +10 -0
- package/types/frontier.d.ts +9 -1
- package/types/incremental-diff.d.ts +57 -0
- package/types/index.d.ts +1 -1
- package/types/interactive-probe.d.ts +5 -1
- package/types/job-queue.d.ts +53 -0
- 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
|
|
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 (
|
|
323
|
+
if (process.env.JOB_QUEUE_BACKEND === "sqs") {
|
|
281
324
|
const { sendJobToSqs } = await import("./sqs-queue.js");
|
|
282
|
-
await sendJobToSqs(job.id
|
|
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."
|
|
665
|
-
AND s."elementHashChangedAt" > tc."
|
|
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."
|
|
1054
|
-
AND s."elementHashChangedAt" > tc."
|
|
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
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|