@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.
- 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 +430 -67
- 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 +525 -69
- 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,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
|
|
255
|
+
logger.error({ err: String(err) }, "[job-worker] stale/retry job sweep failed");
|
|
213
256
|
}
|
|
214
257
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 (
|
|
323
|
+
if (process.env.JOB_QUEUE_BACKEND === "sqs") {
|
|
276
324
|
const { sendJobToSqs } = await import("./sqs-queue.js");
|
|
277
|
-
await sendJobToSqs(job.id
|
|
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."
|
|
660
|
-
AND s."elementHashChangedAt" > tc."
|
|
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."
|
|
1049
|
-
AND s."elementHashChangedAt" > tc."
|
|
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
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|