@detiq/crawler 1.1.21 → 1.1.23
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/Dockerfile +5 -0
- package/dist/crawler.js +20 -7
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +29 -7
- package/dist/job-queue.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +27 -6
- package/src/job-queue.ts +134 -92
- package/types/job-queue.d.ts +3 -1
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -102,7 +102,7 @@ const _crawlLLMCache = new Map<string, any>();
|
|
|
102
102
|
async function getCachedCrawlLLM(tenantId: string): Promise<any> {
|
|
103
103
|
if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
|
|
104
104
|
const cfg = await getTaskAgentLLMConfig(tenantId, 'crawl').catch(() => null);
|
|
105
|
-
_crawlLLMCache.set(tenantId, cfg);
|
|
105
|
+
if (cfg) _crawlLLMCache.set(tenantId, cfg);
|
|
106
106
|
return cfg;
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -128,7 +128,9 @@ interface PerformanceMetric {
|
|
|
128
128
|
const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
|
|
129
129
|
const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
130
130
|
const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
|
|
131
|
-
|
|
131
|
+
// Match 4+ digit pure numeric IDs only — aligns with canonicalize()'s /\d{4,}/ threshold.
|
|
132
|
+
// 1-3 digit segments (page/2/, /tag/books/page/3/) are valid pagination — do NOT skip them.
|
|
133
|
+
const NUMERIC_ID_PATH_SEGMENT_RE = /^\d{4,}$/;
|
|
132
134
|
const COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE = /^\d+-\d+$/;
|
|
133
135
|
|
|
134
136
|
function normalizePathForNumericGuard(pathname: string): string {
|
|
@@ -1955,6 +1957,7 @@ export async function crawlProject(opts: {
|
|
|
1955
1957
|
};
|
|
1956
1958
|
const edges: Array<{ fromUrl: string; toUrl: string }> = [];
|
|
1957
1959
|
const pendingTransitions: Array<{ fromScreenId: string; elementMeaning: string; targetUrl: string }> = [];
|
|
1960
|
+
const pendingSummaryWrites: Promise<void>[] = [];
|
|
1958
1961
|
const discovered: Array<{
|
|
1959
1962
|
url: string; screenId?: string; elements?: number;
|
|
1960
1963
|
elementFacts?: Array<{ meaning: string; role: string; expectedData?: string }>;
|
|
@@ -3450,6 +3453,7 @@ export async function crawlProject(opts: {
|
|
|
3450
3453
|
const summaryPromise = (async () => {
|
|
3451
3454
|
try {
|
|
3452
3455
|
const llmCfg = await getCachedCrawlLLM(opts.tenantId);
|
|
3456
|
+
if (!llmCfg) return undefined;
|
|
3453
3457
|
const snippet = extracted.markdown.slice(0, 1200);
|
|
3454
3458
|
// Skip LLM when content is too thin — output would be useless
|
|
3455
3459
|
if (snippet.trim().length < 80) return undefined;
|
|
@@ -3487,9 +3491,15 @@ export async function crawlProject(opts: {
|
|
|
3487
3491
|
heading: extracted.title || name,
|
|
3488
3492
|
}).catch((err: any) => console.warn('[crawler] markdown extraction save failed:', err));
|
|
3489
3493
|
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3494
|
+
pendingSummaryWrites.push(
|
|
3495
|
+
summaryPromise.then((aiSummary) => {
|
|
3496
|
+
if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch((e: any) => {
|
|
3497
|
+
logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary DB save failed');
|
|
3498
|
+
});
|
|
3499
|
+
}).catch((e: any) => {
|
|
3500
|
+
logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary generation failed');
|
|
3501
|
+
})
|
|
3502
|
+
);
|
|
3493
3503
|
}
|
|
3494
3504
|
|
|
3495
3505
|
const structuredJson = summarizeStructuredElements(atElements);
|
|
@@ -3989,9 +3999,11 @@ export async function crawlProject(opts: {
|
|
|
3989
3999
|
|
|
3990
4000
|
// Discovery mode: return collected URLs and skip all post-crawl analysis phases.
|
|
3991
4001
|
// Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
|
|
4002
|
+
// Filter out sentinel strings (_pending_, _auth_wall_, _soft404_, etc.) that mark
|
|
4003
|
+
// in-progress or skipped entries — only keep actual http(s) URLs.
|
|
3992
4004
|
if (opts.discoveryMode) {
|
|
3993
4005
|
return {
|
|
3994
|
-
discoveredUrls: Array.from(visited.values()),
|
|
4006
|
+
discoveredUrls: Array.from(visited.values()).filter((v: string) => v.startsWith('http')),
|
|
3995
4007
|
screensFound: 0,
|
|
3996
4008
|
screensErrored: 0,
|
|
3997
4009
|
screensSkippedToNextSegment: 0,
|
|
@@ -4768,6 +4780,15 @@ export async function crawlProject(opts: {
|
|
|
4768
4780
|
const screensSucceeded = discovered.filter(d => !!d.screenId).length;
|
|
4769
4781
|
const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
|
|
4770
4782
|
|
|
4783
|
+
// Drain pending AI summary writes before marking the job done — ensures descriptions
|
|
4784
|
+
// are visible immediately after crawl completes. Cap wait at 30 s per screen, 60 s total.
|
|
4785
|
+
if (pendingSummaryWrites.length > 0) {
|
|
4786
|
+
await Promise.race([
|
|
4787
|
+
Promise.allSettled(pendingSummaryWrites),
|
|
4788
|
+
new Promise<void>((resolve) => setTimeout(resolve, Math.min(60_000, pendingSummaryWrites.length * 3_000))),
|
|
4789
|
+
]);
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4771
4792
|
// Detect systematic element/link extraction failure across all screens
|
|
4772
4793
|
if (screensSucceeded >= 3) {
|
|
4773
4794
|
const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
|
package/src/job-queue.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function generateTOTP(secret: string): string {
|
|
|
86
86
|
const hmac = createHmac('sha1', Buffer.from(bytes)).update(buf).digest();
|
|
87
87
|
const offset = hmac[hmac.length - 1] & 0xf;
|
|
88
88
|
const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) |
|
|
89
|
-
|
|
89
|
+
((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
|
|
90
90
|
return String(code % 1_000_000).padStart(6, '0');
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -94,12 +94,12 @@ function deriveFallbackTargetPath(ref: string, framework: string): string {
|
|
|
94
94
|
const slug = (ref ?? 'test').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'test';
|
|
95
95
|
const base = framework.startsWith('cypress') ? 'cypress/e2e/'
|
|
96
96
|
: (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? 'tests/'
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
: (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'src/test/java/'
|
|
98
|
+
: 'tests/';
|
|
99
99
|
const ext = (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? '_test.py'
|
|
100
100
|
: (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'Test.java'
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
: framework.includes('ts') ? '.spec.ts'
|
|
102
|
+
: '.spec.js';
|
|
103
103
|
return `${base}${slug}${ext}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -109,23 +109,23 @@ function makeJobName(type: string): string {
|
|
|
109
109
|
const ts = now.getTime();
|
|
110
110
|
const label = type === 'CRAWL' ? 'Crawl'
|
|
111
111
|
: type === 'CAPTURE_STEPS' ? 'CaptureSteps'
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
112
|
+
: type === 'NATIVE_CRAWL' ? 'NativeCrawl'
|
|
113
|
+
: type === 'GENERATE_TESTS' ? 'GenerateTests'
|
|
114
|
+
: type === 'VISUAL_REGRESSION' ? 'VisualRegression'
|
|
115
|
+
: type === 'AI_EDIT' ? 'AIEdit'
|
|
116
|
+
: type === 'API_TESTS' ? 'ApiTests'
|
|
117
|
+
: type === 'SECURITY_TESTS' ? 'SecurityTests'
|
|
118
|
+
: type === 'INTEGRATION_TESTS' ? 'IntegrationTests'
|
|
119
|
+
: type === 'GENERATE_TESTS_FROM_REQS' ? 'GenerateTestsFromReqs'
|
|
120
|
+
: type === 'GENERATE_DATA' ? 'GenerateData'
|
|
121
|
+
: type === 'GENERATE_SCRIPT' ? 'GenerateScript'
|
|
122
|
+
: type === 'GENERATE_TEST_PLAN' ? 'GenerateTestPlan'
|
|
123
|
+
: type === 'IMPORT_TESTS' ? 'ImportTests'
|
|
124
|
+
: type === 'GENERATE_SCREEN_TESTS' ? 'GenerateScreenTests'
|
|
125
|
+
: type === 'BATCH_GENERATE_ALIGNED' ? 'BatchGenerateAligned'
|
|
126
|
+
: type === 'CREATE_PR' ? 'CreatePR'
|
|
127
|
+
: type === 'NEW_PAGES_PIPELINE' ? 'NewPagesPipeline'
|
|
128
|
+
: type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
129
129
|
return `${label}_${date}_${ts}`;
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -195,6 +195,9 @@ const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
|
195
195
|
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
196
196
|
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
|
|
197
197
|
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
198
|
+
// Max simultaneous DISCOVER jobs — each launches a Playwright browser and holds
|
|
199
|
+
// DB connections, so running many concurrently exhausts resources. Default 2.
|
|
200
|
+
const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
|
|
198
201
|
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
|
|
199
202
|
// How many headless browsers a single RUN_TEST_SUITE job launches at once —
|
|
200
203
|
// distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
|
|
@@ -221,14 +224,21 @@ export async function claimSpecificJob(jobId: string): Promise<ClaimedJob | null
|
|
|
221
224
|
}
|
|
222
225
|
|
|
223
226
|
/** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
|
|
224
|
-
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
227
|
+
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
228
|
+
* DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
|
|
229
|
+
* already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
|
|
225
230
|
export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
|
|
231
|
+
const maxDiscover = MAX_CONCURRENT_DISCOVER;
|
|
226
232
|
const rows = await prismaAdmin.$queryRaw<ClaimedJob[]>`
|
|
227
233
|
UPDATE "PlatformJob"
|
|
228
234
|
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
229
235
|
WHERE id = (
|
|
230
236
|
SELECT id FROM "PlatformJob"
|
|
231
237
|
WHERE status = 'QUEUED'
|
|
238
|
+
AND (
|
|
239
|
+
type != 'DISCOVER'
|
|
240
|
+
OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
|
|
241
|
+
)
|
|
232
242
|
ORDER BY "queuedAt" ASC
|
|
233
243
|
FOR UPDATE SKIP LOCKED
|
|
234
244
|
LIMIT 1
|
|
@@ -315,7 +325,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
|
315
325
|
await prismaAdmin.platformJob.update({
|
|
316
326
|
where: { id: job.id },
|
|
317
327
|
data: { status: 'FAILED', error: `Unknown job type: ${job.type}`, finishedAt: new Date() },
|
|
318
|
-
}).catch(() => {});
|
|
328
|
+
}).catch(() => { });
|
|
319
329
|
return;
|
|
320
330
|
}
|
|
321
331
|
const stopTimer = crawlJobDurationSeconds.startTimer({ type: job.type });
|
|
@@ -335,7 +345,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
|
335
345
|
await prismaAdmin.platformJob.update({
|
|
336
346
|
where: { id: job.id },
|
|
337
347
|
data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
|
|
338
|
-
}).catch(() => {});
|
|
348
|
+
}).catch(() => { });
|
|
339
349
|
}
|
|
340
350
|
}
|
|
341
351
|
|
|
@@ -362,10 +372,15 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
|
|
|
362
372
|
logger.error({ err: String(err) }, '[job-worker] stale-job sweep failed');
|
|
363
373
|
}
|
|
364
374
|
try {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
375
|
+
const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
|
|
376
|
+
if (pauseRow?.value === 'true') {
|
|
377
|
+
logger.info('[job-worker] queue is paused — skipping claim');
|
|
378
|
+
} else {
|
|
379
|
+
for (let i = 0; i < concurrency; i++) {
|
|
380
|
+
const job = await claimNextQueuedJob();
|
|
381
|
+
if (!job) break;
|
|
382
|
+
void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
|
|
383
|
+
}
|
|
369
384
|
}
|
|
370
385
|
} catch (err) {
|
|
371
386
|
logger.error({ err: String(err) }, '[job-worker] claim loop failed');
|
|
@@ -390,7 +405,7 @@ export function stopJobWorkerLoop(): void {
|
|
|
390
405
|
/** Create a job record that tracks a synchronous operation (telemetry only, no background processor). */
|
|
391
406
|
export async function createTrackedJob(
|
|
392
407
|
type: string,
|
|
393
|
-
config: { tenantId: string; projectId: string;
|
|
408
|
+
config: { tenantId: string; projectId: string;[k: string]: any }
|
|
394
409
|
): Promise<string> {
|
|
395
410
|
const name = makeJobName(type);
|
|
396
411
|
const job = await withTenant(config.tenantId, (tx) =>
|
|
@@ -414,7 +429,7 @@ export async function finishTrackedJob(tenantId: string, jobId: string, error?:
|
|
|
414
429
|
...(error ? { error, nextRetryAt } : { progress: 100 }),
|
|
415
430
|
},
|
|
416
431
|
})
|
|
417
|
-
).catch(() => {});
|
|
432
|
+
).catch(() => { });
|
|
418
433
|
}
|
|
419
434
|
|
|
420
435
|
/** Enqueue a generic platform job — returns the job ID immediately. */
|
|
@@ -691,11 +706,11 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
691
706
|
// Build runtime credentials: convert stored totpSecret → getOneTimeCode function
|
|
692
707
|
const runtimeCreds = config.credentials
|
|
693
708
|
? {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
709
|
+
...config.credentials,
|
|
710
|
+
...(config.credentials.totpSecret
|
|
711
|
+
? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
|
|
712
|
+
: {}),
|
|
713
|
+
}
|
|
699
714
|
: undefined;
|
|
700
715
|
|
|
701
716
|
const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
@@ -997,11 +1012,11 @@ export async function aggregateJobTokens(tenantId: string, jobId: string, projec
|
|
|
997
1012
|
await tx.platformJob.update({
|
|
998
1013
|
where: { id: jobId },
|
|
999
1014
|
data: {
|
|
1000
|
-
totalPromptTokens:
|
|
1015
|
+
totalPromptTokens: row.totalPromptTokens,
|
|
1001
1016
|
totalCompletionTokens: row.totalCompletionTokens,
|
|
1002
|
-
totalCacheReadTokens:
|
|
1017
|
+
totalCacheReadTokens: row.totalCacheReadTokens,
|
|
1003
1018
|
totalEstimatedCostUsd: Number(row.totalEstimatedCostUsd),
|
|
1004
|
-
llmCallCount:
|
|
1019
|
+
llmCallCount: row.llmCallCount,
|
|
1005
1020
|
},
|
|
1006
1021
|
});
|
|
1007
1022
|
});
|
|
@@ -1180,10 +1195,10 @@ async function processFigmaImportJob(jobId: string, config: any) {
|
|
|
1180
1195
|
const figmaElements = figma.extractElements((frame as any).node);
|
|
1181
1196
|
for (const el of figmaElements) {
|
|
1182
1197
|
const role = el.type === 'COMPONENT' || el.type === 'COMPONENT_SET' ? 'COMPONENT'
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1198
|
+
: el.type === 'INSTANCE' ? 'COMPONENT'
|
|
1199
|
+
: el.type === 'TEXT' ? 'TEXT'
|
|
1200
|
+
: el.type === 'FRAME' || el.type === 'GROUP' ? 'REGION'
|
|
1201
|
+
: el.type;
|
|
1187
1202
|
const boundingRect = el.boundingBox
|
|
1188
1203
|
? { x: Math.round(el.boundingBox.x), y: Math.round(el.boundingBox.y), width: Math.round(el.boundingBox.width), height: Math.round(el.boundingBox.height) }
|
|
1189
1204
|
: undefined;
|
|
@@ -1261,7 +1276,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
|
|
|
1261
1276
|
deviceFarm,
|
|
1262
1277
|
},
|
|
1263
1278
|
})
|
|
1264
|
-
).catch(() => {});
|
|
1279
|
+
).catch(() => { });
|
|
1265
1280
|
|
|
1266
1281
|
const workspaceDeviceFarm = await AppBrain.getTenantDeviceFarmConfig(tenantId).catch(() => null);
|
|
1267
1282
|
|
|
@@ -1328,7 +1343,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
|
|
|
1328
1343
|
} catch (err: any) {
|
|
1329
1344
|
await withTenant(tenantId, (tx) =>
|
|
1330
1345
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
|
|
1331
|
-
).catch(() => {});
|
|
1346
|
+
).catch(() => { });
|
|
1332
1347
|
}
|
|
1333
1348
|
}
|
|
1334
1349
|
|
|
@@ -1387,7 +1402,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1387
1402
|
try {
|
|
1388
1403
|
const reqSeed = deterministicSeed(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
|
|
1389
1404
|
const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
|
|
1390
|
-
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => {});
|
|
1405
|
+
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
|
|
1391
1406
|
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
1392
1407
|
if (json) parsed = JSON.parse(json);
|
|
1393
1408
|
} catch (e: any) {
|
|
@@ -1396,8 +1411,8 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1396
1411
|
|
|
1397
1412
|
await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
|
|
1398
1413
|
|
|
1399
|
-
const VALID_KINDS = new Set(['POSITIVE','NEGATIVE','EDGE','STATE_TRANSITION','ACCESSIBILITY','SECURITY','INTEGRATION','E2E','API','EXPLORATORY','UAT','PERFORMANCE','COMPATIBILITY','L10N','USABILITY','LOAD','UNIT','MUTATION','AB_TEST','ALPHA_BETA','SYSTEM','SMOKE','REGRESSION','SANITY']);
|
|
1400
|
-
const VALID_PRIORITIES = new Set(['CRITICAL','HIGH','MEDIUM','LOW']);
|
|
1414
|
+
const VALID_KINDS = new Set(['POSITIVE', 'NEGATIVE', 'EDGE', 'STATE_TRANSITION', 'ACCESSIBILITY', 'SECURITY', 'INTEGRATION', 'E2E', 'API', 'EXPLORATORY', 'UAT', 'PERFORMANCE', 'COMPATIBILITY', 'L10N', 'USABILITY', 'LOAD', 'UNIT', 'MUTATION', 'AB_TEST', 'ALPHA_BETA', 'SYSTEM', 'SMOKE', 'REGRESSION', 'SANITY']);
|
|
1415
|
+
const VALID_PRIORITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
|
1401
1416
|
const normalized = (parsed.cases ?? []).map((c: any) => ({
|
|
1402
1417
|
ref: c.reqRef ?? null,
|
|
1403
1418
|
traceTo: c.reqRef ?? null,
|
|
@@ -1419,7 +1434,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1419
1434
|
await Promise.all(
|
|
1420
1435
|
reqs
|
|
1421
1436
|
.filter((r: any) => coveredReqIds.has(r.reqId))
|
|
1422
|
-
.map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => {}))
|
|
1437
|
+
.map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => { }))
|
|
1423
1438
|
);
|
|
1424
1439
|
|
|
1425
1440
|
await withTenant(tenantId, (tx) =>
|
|
@@ -1433,7 +1448,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1433
1448
|
logger.error({ jobId, err: msg }, 'processGenerateTestsFromReqsJob failed');
|
|
1434
1449
|
await withTenant(tenantId, (tx) =>
|
|
1435
1450
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
|
|
1436
|
-
).catch(() => {});
|
|
1451
|
+
).catch(() => { });
|
|
1437
1452
|
}
|
|
1438
1453
|
}
|
|
1439
1454
|
|
|
@@ -1544,7 +1559,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1544
1559
|
} : {}),
|
|
1545
1560
|
});
|
|
1546
1561
|
}
|
|
1547
|
-
await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => {});
|
|
1562
|
+
await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => { });
|
|
1548
1563
|
} catch (err: any) {
|
|
1549
1564
|
failed++;
|
|
1550
1565
|
runResults.push({ testCaseId: tc.id, status: 'ERROR' });
|
|
@@ -1553,7 +1568,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1553
1568
|
testCaseId: tc.id,
|
|
1554
1569
|
status: 'ERROR',
|
|
1555
1570
|
error: String(err?.message ?? err).slice(0, 500),
|
|
1556
|
-
}).catch(() => {});
|
|
1571
|
+
}).catch(() => { });
|
|
1557
1572
|
}
|
|
1558
1573
|
}));
|
|
1559
1574
|
await updateJobProgress(
|
|
@@ -1592,10 +1607,10 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1592
1607
|
} catch (err: any) {
|
|
1593
1608
|
const msg = String(err?.message ?? err).slice(0, 500);
|
|
1594
1609
|
logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
|
|
1595
|
-
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {});
|
|
1610
|
+
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => { });
|
|
1596
1611
|
await withTenant(tenantId, (tx) =>
|
|
1597
1612
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
|
|
1598
|
-
).catch(() => {});
|
|
1613
|
+
).catch(() => { });
|
|
1599
1614
|
}
|
|
1600
1615
|
}
|
|
1601
1616
|
|
|
@@ -1840,12 +1855,12 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1840
1855
|
await onProgress(52, 'Authenticated spider…');
|
|
1841
1856
|
const runtimeCreds = crawlCfg.username
|
|
1842
1857
|
? {
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1858
|
+
username: crawlCfg.username,
|
|
1859
|
+
password: crawlCfg.password ?? '',
|
|
1860
|
+
...(crawlCfg.totpSecret
|
|
1861
|
+
? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret!) }
|
|
1862
|
+
: {}),
|
|
1863
|
+
}
|
|
1849
1864
|
: undefined;
|
|
1850
1865
|
|
|
1851
1866
|
const authResult = await crawlProject({
|
|
@@ -1897,7 +1912,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1897
1912
|
}
|
|
1898
1913
|
|
|
1899
1914
|
// Persist discovered URLs to project crawl config
|
|
1900
|
-
|
|
1915
|
+
// Filter sentinel strings (_pending_, _auth_wall_, etc.) that may have leaked in
|
|
1916
|
+
// via onUrlsDiscovered callbacks fired before visited-map entries were updated.
|
|
1917
|
+
const discoveredUrls = Array.from(allUrls).filter(u => u.startsWith('http'));
|
|
1901
1918
|
const existingCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => ({}));
|
|
1902
1919
|
await AppBrain.saveCrawlConfig(tenantId, projectId, {
|
|
1903
1920
|
...(existingCfg as any),
|
|
@@ -2164,7 +2181,7 @@ async function processImportTestsJob(jobId: string, config: any) {
|
|
|
2164
2181
|
status: lr.needsReview ? 'PENDING_REVIEW' : undefined,
|
|
2165
2182
|
},
|
|
2166
2183
|
})
|
|
2167
|
-
).catch(() => {}) // non-fatal per-case
|
|
2184
|
+
).catch(() => { }) // non-fatal per-case
|
|
2168
2185
|
)
|
|
2169
2186
|
);
|
|
2170
2187
|
labeled = labelResults.length;
|
|
@@ -2258,7 +2275,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
|
|
|
2258
2275
|
testCaseId: tc.id,
|
|
2259
2276
|
styleProfile: conn.styleProfile,
|
|
2260
2277
|
pageObjectInventory: conn.pageObjectInventory ?? undefined,
|
|
2261
|
-
}).catch(() => {})
|
|
2278
|
+
}).catch(() => { })
|
|
2262
2279
|
);
|
|
2263
2280
|
}
|
|
2264
2281
|
}
|
|
@@ -2272,7 +2289,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
|
|
|
2272
2289
|
logger.error({ jobId, screenId, err: msg }, 'processGenerateScreenTestsJob failed');
|
|
2273
2290
|
await withTenant(tenantId, (tx: any) =>
|
|
2274
2291
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg.slice(0, 500), finishedAt: new Date() } })
|
|
2275
|
-
).catch(() => {});
|
|
2292
|
+
).catch(() => { });
|
|
2276
2293
|
}
|
|
2277
2294
|
}
|
|
2278
2295
|
|
|
@@ -2377,10 +2394,35 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2377
2394
|
if (!tc) throw new Error('Test case not found');
|
|
2378
2395
|
|
|
2379
2396
|
const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2380
|
-
|
|
2397
|
+
if (rawSteps.length === 0) throw new Error('Test case has no steps to capture');
|
|
2398
|
+
|
|
2399
|
+
// Resolve env tokens from testDataInline so {{fze.env.URL}} → concrete URL before execution plan
|
|
2400
|
+
const _tdi = ((tc as any).testDataInline ?? {}) as Record<string, unknown>;
|
|
2401
|
+
const _tdiStartUrl = typeof _tdi['startUrl'] === 'string' && (_tdi['startUrl'] as string).startsWith('http')
|
|
2402
|
+
? (_tdi['startUrl'] as string) : '';
|
|
2403
|
+
const _recordedUrls = Array.isArray(_tdi['recordedUrls']) ? (_tdi['recordedUrls'] as unknown[]).map(String) : [];
|
|
2404
|
+
const _recordedStartUrl = _recordedUrls.find((u) => u.startsWith('http')) ?? '';
|
|
2405
|
+
|
|
2406
|
+
const baseUrl = config.baseUrl || _tdiStartUrl || _recordedStartUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
|
|
2407
|
+
|
|
2408
|
+
// Build env token → concrete value map from testDataInline for step text substitution
|
|
2409
|
+
const _envTokenMap: Record<string, string> = {};
|
|
2410
|
+
for (const [k, v] of Object.entries(_tdi)) {
|
|
2411
|
+
if (typeof v === 'string' && v.startsWith('http')) _envTokenMap[k] = v;
|
|
2412
|
+
}
|
|
2413
|
+
if (_recordedStartUrl && !_envTokenMap['startUrl']) _envTokenMap['startUrl'] = _recordedStartUrl;
|
|
2414
|
+
|
|
2415
|
+
// Replace {{fze.env.X}} and [env:X] tokens in step texts with concrete values from testDataInline.
|
|
2416
|
+
// IMPORTANT: only substitute if we have a concrete value for that key.
|
|
2417
|
+
// Fall back to [key] placeholder — never fall back to baseUrl (would inject URL into credential fields).
|
|
2418
|
+
const resolveStepText = (text: string): string =>
|
|
2419
|
+
text
|
|
2420
|
+
.replace(/\{\{\s*fze\.env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`)
|
|
2421
|
+
.replace(/\[env:([A-Za-z_][A-Za-z0-9_]*)\]/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`);
|
|
2422
|
+
|
|
2423
|
+
const stepTexts = rawSteps.map(captureStepText).filter(Boolean).map(resolveStepText);
|
|
2381
2424
|
if (stepTexts.length === 0) throw new Error('Test case has no steps to capture');
|
|
2382
2425
|
|
|
2383
|
-
const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
|
|
2384
2426
|
let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
|
|
2385
2427
|
plan = Array.isArray(plan)
|
|
2386
2428
|
? plan.filter(Boolean).map((step: any, idx: number) => ({ ...step, stepNumber: idx + 1 }))
|
|
@@ -2424,7 +2466,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2424
2466
|
heartbeatAt: new Date(),
|
|
2425
2467
|
},
|
|
2426
2468
|
})
|
|
2427
|
-
).catch(() => {});
|
|
2469
|
+
).catch(() => { });
|
|
2428
2470
|
|
|
2429
2471
|
try {
|
|
2430
2472
|
const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
|
|
@@ -2433,7 +2475,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2433
2475
|
result.healTier = heal.healedVia;
|
|
2434
2476
|
result.healReasoning = heal.healReasoning;
|
|
2435
2477
|
}
|
|
2436
|
-
await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => {});
|
|
2478
|
+
await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => { });
|
|
2437
2479
|
result.status = result.healed ? 'HEALED' : 'PASSED';
|
|
2438
2480
|
} catch (err: any) {
|
|
2439
2481
|
result.status = step.optional ? 'SKIPPED' : 'FAILED';
|
|
@@ -2444,7 +2486,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2444
2486
|
result.url = currentUrl;
|
|
2445
2487
|
const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === 'FAILED' ? '-err' : ''}.png`;
|
|
2446
2488
|
const ssPath = path.join(screenshotDir, ssFilename);
|
|
2447
|
-
await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {});
|
|
2489
|
+
await page.screenshot({ path: ssPath, fullPage: false }).catch(() => { });
|
|
2448
2490
|
result.screenshotPath = ssPath;
|
|
2449
2491
|
|
|
2450
2492
|
let cloudUrl: string | undefined;
|
|
@@ -2466,13 +2508,13 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2466
2508
|
const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? 'ENTRY_POINT' : 'LINK_FOLLOW').catch(() => null) as any;
|
|
2467
2509
|
if (screen?.id) {
|
|
2468
2510
|
result.screenId = screen.id;
|
|
2469
|
-
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => {});
|
|
2511
|
+
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => { });
|
|
2470
2512
|
for (const el of elements) {
|
|
2471
2513
|
await AppBrain.saveElement(
|
|
2472
2514
|
tenantId, projectId, screen.id,
|
|
2473
2515
|
el.meaning, el.role, el.expectedData, el.notes,
|
|
2474
2516
|
undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
|
|
2475
|
-
).catch(() => {});
|
|
2517
|
+
).catch(() => { });
|
|
2476
2518
|
}
|
|
2477
2519
|
for (const loc of elementsToPlaywrightLocators(elements as any)) {
|
|
2478
2520
|
if (seenLocatorExpressions.has(loc.expression)) continue;
|
|
@@ -2491,7 +2533,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2491
2533
|
}
|
|
2492
2534
|
|
|
2493
2535
|
if (locators.length > 0) {
|
|
2494
|
-
await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => {});
|
|
2536
|
+
await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => { });
|
|
2495
2537
|
}
|
|
2496
2538
|
|
|
2497
2539
|
const firstScreen = screens[0];
|
|
@@ -2503,7 +2545,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2503
2545
|
captureStepScreenshots: stepResults as any,
|
|
2504
2546
|
},
|
|
2505
2547
|
})
|
|
2506
|
-
).catch(() => {});
|
|
2548
|
+
).catch(() => { });
|
|
2507
2549
|
|
|
2508
2550
|
const finalResult = {
|
|
2509
2551
|
stepResults,
|
|
@@ -2522,11 +2564,11 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2522
2564
|
const error = String(err?.message ?? err);
|
|
2523
2565
|
await withTenant(tenantId, (tx) =>
|
|
2524
2566
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
|
|
2525
|
-
).catch(() => {});
|
|
2567
|
+
).catch(() => { });
|
|
2526
2568
|
logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
|
|
2527
2569
|
throw err;
|
|
2528
2570
|
} finally {
|
|
2529
|
-
await browser?.close?.().catch(() => {});
|
|
2571
|
+
await browser?.close?.().catch(() => { });
|
|
2530
2572
|
}
|
|
2531
2573
|
}
|
|
2532
2574
|
|
|
@@ -2562,28 +2604,28 @@ jobDispatchTable = {
|
|
|
2562
2604
|
await prismaAdmin.platformJob.update({
|
|
2563
2605
|
where: { id: jobId },
|
|
2564
2606
|
data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2565
|
-
}).catch(() => {});
|
|
2607
|
+
}).catch(() => { });
|
|
2566
2608
|
},
|
|
2567
2609
|
GENERATE_DATA: async (jobId: string, _config: any) => {
|
|
2568
2610
|
logger.error({ jobId }, '[job-worker] GENERATE_DATA claimed by worker — API did not process it in-process');
|
|
2569
2611
|
await prismaAdmin.platformJob.update({
|
|
2570
2612
|
where: { id: jobId },
|
|
2571
2613
|
data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2572
|
-
}).catch(() => {});
|
|
2614
|
+
}).catch(() => { });
|
|
2573
2615
|
},
|
|
2574
2616
|
GENERATE_SCRIPT: async (jobId: string, _config: any) => {
|
|
2575
2617
|
logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
|
|
2576
2618
|
await prismaAdmin.platformJob.update({
|
|
2577
2619
|
where: { id: jobId },
|
|
2578
2620
|
data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2579
|
-
}).catch(() => {});
|
|
2621
|
+
}).catch(() => { });
|
|
2580
2622
|
},
|
|
2581
2623
|
GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
|
|
2582
2624
|
logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
|
|
2583
2625
|
await prismaAdmin.platformJob.update({
|
|
2584
2626
|
where: { id: jobId },
|
|
2585
2627
|
data: { status: 'FAILED', error: 'Test plan generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2586
|
-
}).catch(() => {});
|
|
2628
|
+
}).catch(() => { });
|
|
2587
2629
|
},
|
|
2588
2630
|
};
|
|
2589
2631
|
|
|
@@ -2880,14 +2922,14 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2880
2922
|
const testPathSet = new Set(testPaths);
|
|
2881
2923
|
const sourceFilePaths = (language === 'typescript' || language === 'javascript')
|
|
2882
2924
|
? treeResult.paths
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2925
|
+
.filter((p: string) =>
|
|
2926
|
+
(p.endsWith('.ts') || p.endsWith('.js')) &&
|
|
2927
|
+
!testPathSet.has(p) &&
|
|
2928
|
+
!p.endsWith('.d.ts') &&
|
|
2929
|
+
!p.includes('node_modules/') &&
|
|
2930
|
+
!p.includes('dist/') &&
|
|
2931
|
+
!p.includes('.config.'))
|
|
2932
|
+
.slice(0, 60)
|
|
2891
2933
|
: [];
|
|
2892
2934
|
const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
|
|
2893
2935
|
// Pass full tree paths separately so detectDataDirs can find testdata/*.json, fixtures/*.yaml etc.
|
|
@@ -3019,10 +3061,10 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
3019
3061
|
logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
|
|
3020
3062
|
await withTenant(tenantId, (tx) =>
|
|
3021
3063
|
tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })
|
|
3022
|
-
).catch(() => {});
|
|
3064
|
+
).catch(() => { });
|
|
3023
3065
|
await withTenant(tenantId, (tx) =>
|
|
3024
3066
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3025
|
-
).catch(() => {});
|
|
3067
|
+
).catch(() => { });
|
|
3026
3068
|
}
|
|
3027
3069
|
}
|
|
3028
3070
|
|
|
@@ -3147,7 +3189,7 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
|
|
|
3147
3189
|
logger.error({ jobId, err: msg }, '[batch-generate-aligned] failed');
|
|
3148
3190
|
await withTenant(tenantId, (tx) =>
|
|
3149
3191
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3150
|
-
).catch(() => {});
|
|
3192
|
+
).catch(() => { });
|
|
3151
3193
|
}
|
|
3152
3194
|
}
|
|
3153
3195
|
|
|
@@ -3276,7 +3318,7 @@ async function processCreatePRJob(jobId: string, config: any) {
|
|
|
3276
3318
|
logger.error({ jobId, err: msg }, '[create-pr] failed');
|
|
3277
3319
|
await withTenant(tenantId, (tx) =>
|
|
3278
3320
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3279
|
-
).catch(() => {});
|
|
3321
|
+
).catch(() => { });
|
|
3280
3322
|
}
|
|
3281
3323
|
}
|
|
3282
3324
|
|
|
@@ -3380,6 +3422,6 @@ async function processNewPagesPipelineJob(jobId: string, config: any) {
|
|
|
3380
3422
|
logger.error({ jobId, err: msg }, '[new-pages-pipeline] failed');
|
|
3381
3423
|
await withTenant(tenantId, (tx) =>
|
|
3382
3424
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3383
|
-
).catch(() => {});
|
|
3425
|
+
).catch(() => { });
|
|
3384
3426
|
}
|
|
3385
3427
|
}
|
package/types/job-queue.d.ts
CHANGED
|
@@ -78,7 +78,9 @@ type ClaimedJob = {
|
|
|
78
78
|
/** Atomically claims one specific QUEUED job for this worker. Returns null if it was already claimed. */
|
|
79
79
|
export declare function claimSpecificJob(jobId: string): Promise<ClaimedJob | null>;
|
|
80
80
|
/** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
|
|
81
|
-
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
81
|
+
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
82
|
+
* DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
|
|
83
|
+
* already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
|
|
82
84
|
export declare function claimNextQueuedJob(): Promise<ClaimedJob | null>;
|
|
83
85
|
/** Requeues RUNNING jobs whose heartbeat has gone stale — the worker that claimed them
|
|
84
86
|
* died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and
|