@detiq/crawler 1.1.25 → 1.1.27
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 +2 -3
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +299 -64
- package/dist/job-queue.js.map +3 -3
- package/package.json +1 -1
- package/src/crawler.ts +5 -2
- package/src/failure-analysis-agent.ts +2 -1
- package/src/job-queue.ts +373 -74
- package/types/crawler.d.ts +4 -0
- package/types/job-queue.d.ts +44 -3
package/src/job-queue.ts
CHANGED
|
@@ -18,40 +18,18 @@ import crypto from 'node:crypto';
|
|
|
18
18
|
import { withTenant, prismaAdmin } from '@detiq/database';
|
|
19
19
|
import { crawlJobsTotal, crawlJobDurationSeconds } from '@detiq/core';
|
|
20
20
|
import { logger } from './logger.js';
|
|
21
|
+
import { AppBrain, getProjectStorageConfig, buildArtifactKey, uploadArtifact, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall, downloadBufferFromTenantStorage, dispatchToTenantAdmins } from '@detiq/app-brain';
|
|
21
22
|
|
|
22
23
|
async function notifyAdmins(tenantId: string, data: {
|
|
23
24
|
type: string; title: string; body: string; metadata?: Record<string, unknown>
|
|
24
25
|
}) {
|
|
25
26
|
try {
|
|
26
|
-
|
|
27
|
-
// prisma call with no app.current_tenant set gets silently filtered to
|
|
28
|
-
// zero rows (SELECT) or rejected (INSERT), not an error, so this call
|
|
29
|
-
// site had been a complete no-op with no visible failure until fixed.
|
|
30
|
-
const members = await withTenant(tenantId, (tx) =>
|
|
31
|
-
tx.membership.findMany({
|
|
32
|
-
where: { tenantId, role: { in: ['OWNER', 'ADMIN'] } },
|
|
33
|
-
select: { userId: true },
|
|
34
|
-
})
|
|
35
|
-
) as any[];
|
|
36
|
-
await withTenant(tenantId, (tx) =>
|
|
37
|
-
tx.notification.createMany({
|
|
38
|
-
data: members.map((m: { userId: string }) => ({
|
|
39
|
-
id: `notif_${crypto.randomUUID()}`,
|
|
40
|
-
tenantId,
|
|
41
|
-
userId: m.userId,
|
|
42
|
-
type: data.type,
|
|
43
|
-
title: data.title,
|
|
44
|
-
body: data.body,
|
|
45
|
-
metadata: data.metadata as any,
|
|
46
|
-
})),
|
|
47
|
-
})
|
|
48
|
-
);
|
|
27
|
+
await dispatchToTenantAdmins(tenantId, data as any);
|
|
49
28
|
} catch (e) {
|
|
50
29
|
console.error('[notify] failed:', e);
|
|
51
30
|
}
|
|
52
31
|
}
|
|
53
|
-
import {
|
|
54
|
-
import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from '@detiq/agents';
|
|
32
|
+
import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog, generateArtifactIntelligence } from '@detiq/agents';
|
|
55
33
|
import type { ExecutionStep } from '@detiq/agents';
|
|
56
34
|
import { crawlProject, domFallbackElements } from './crawler.js';
|
|
57
35
|
import { httpCrawl } from './http-engine.js';
|
|
@@ -67,6 +45,70 @@ import fs from 'node:fs/promises';
|
|
|
67
45
|
import path from 'node:path';
|
|
68
46
|
import os from 'node:os';
|
|
69
47
|
|
|
48
|
+
// ── GAP-14/15: Structured failure classification with per-type backoff ────────────────
|
|
49
|
+
|
|
50
|
+
export enum FailureType {
|
|
51
|
+
TRANSIENT = 'TRANSIENT', // network timeout, rate limit, 429, 503
|
|
52
|
+
INFRASTRUCTURE = 'INFRASTRUCTURE', // DB connection, OOM, worker crash
|
|
53
|
+
LOGICAL = 'LOGICAL', // bad input, business rule violation, 400/422
|
|
54
|
+
FATAL = 'FATAL', // unrecoverable — bad config, missing secrets
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface BackoffConfig {
|
|
58
|
+
maxRetries: number;
|
|
59
|
+
baseDelayMs: number;
|
|
60
|
+
multiplier: number;
|
|
61
|
+
maxDelayMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const BACKOFF_BY_TYPE: Record<FailureType, BackoffConfig> = {
|
|
65
|
+
[FailureType.TRANSIENT]: { maxRetries: 5, baseDelayMs: 30_000, multiplier: 2, maxDelayMs: 300_000 },
|
|
66
|
+
[FailureType.INFRASTRUCTURE]: { maxRetries: 3, baseDelayMs: 60_000, multiplier: 3, maxDelayMs: 600_000 },
|
|
67
|
+
[FailureType.LOGICAL]: { maxRetries: 1, baseDelayMs: 120_000, multiplier: 1, maxDelayMs: 120_000 },
|
|
68
|
+
[FailureType.FATAL]: { maxRetries: 0, baseDelayMs: 0, multiplier: 1, maxDelayMs: 0 },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function classifyFailure(error: string | Error | unknown): FailureType {
|
|
72
|
+
const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
73
|
+
|
|
74
|
+
// Fatal — bad configuration, missing secrets
|
|
75
|
+
if (msg.includes('master_enc_key') || msg.includes('missing secret') || msg.includes('invalid configuration')) {
|
|
76
|
+
return FailureType.FATAL;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Infrastructure — OOM, DB, worker crashes
|
|
80
|
+
if (
|
|
81
|
+
msg.includes('out of memory') || msg.includes('heap out') ||
|
|
82
|
+
msg.includes('connection refused') || msg.includes('econnrefused') ||
|
|
83
|
+
msg.includes('prisma') || msg.includes('database') ||
|
|
84
|
+
msg.includes('worker exited') || msg.includes('sigkill')
|
|
85
|
+
) {
|
|
86
|
+
return FailureType.INFRASTRUCTURE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Logical — validation, auth, bad input
|
|
90
|
+
if (
|
|
91
|
+
msg.includes('validation') || msg.includes('invalid input') ||
|
|
92
|
+
msg.includes('not found') || msg.includes('unauthorized') ||
|
|
93
|
+
msg.includes('bad request') || msg.includes('400') || msg.includes('422') ||
|
|
94
|
+
msg.includes('403')
|
|
95
|
+
) {
|
|
96
|
+
return FailureType.LOGICAL;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Default: TRANSIENT (network, timeout, rate limit, 429, 503)
|
|
100
|
+
return FailureType.TRANSIENT;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function computeNextRetryAt(failureType: FailureType, retryCount: number): Date | null {
|
|
104
|
+
const config = BACKOFF_BY_TYPE[failureType];
|
|
105
|
+
if (retryCount >= config.maxRetries) return null; // exceeded max — go to DLQ
|
|
106
|
+
const delayMs = Math.min(config.baseDelayMs * Math.pow(config.multiplier, retryCount), config.maxDelayMs);
|
|
107
|
+
return new Date(Date.now() + delayMs);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
70
112
|
// RFC 6238 TOTP — no external dependency, uses Node built-in crypto.
|
|
71
113
|
export function generateTOTP(secret: string): string {
|
|
72
114
|
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
@@ -227,8 +269,24 @@ const runningControllers = new Map<string, AbortController>();
|
|
|
227
269
|
|
|
228
270
|
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
229
271
|
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
272
|
+
const MAX_CONCURRENT_PER_TENANT = Number(process.env.MAX_CONCURRENT_PER_TENANT) || 4;
|
|
230
273
|
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
|
|
231
274
|
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
275
|
+
|
|
276
|
+
// GAP-12: true active-execution semaphore — counts jobs currently executing
|
|
277
|
+
// (claim→finish), not poll slots. A single poll loop checks this before every
|
|
278
|
+
// claim so the cap is never exceeded even across overlapping ticks.
|
|
279
|
+
let activeExecutions = 0;
|
|
280
|
+
|
|
281
|
+
function withSemaphore<T>(fn: () => Promise<T>): Promise<T> {
|
|
282
|
+
activeExecutions++;
|
|
283
|
+
return fn().finally(() => { activeExecutions--; });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function getActiveExecutionCount(): number {
|
|
287
|
+
return activeExecutions;
|
|
288
|
+
}
|
|
289
|
+
|
|
232
290
|
// Max simultaneous DISCOVER jobs — each launches a Playwright browser and holds
|
|
233
291
|
// DB connections, so running many concurrently exhausts resources. Default 2.
|
|
234
292
|
const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
|
|
@@ -252,6 +310,7 @@ export async function claimSpecificJob(jobId: string): Promise<ClaimedJob | null
|
|
|
252
310
|
UPDATE "PlatformJob"
|
|
253
311
|
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
254
312
|
WHERE id = ${jobId} AND status = 'QUEUED'
|
|
313
|
+
AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
|
|
255
314
|
RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
|
|
256
315
|
`;
|
|
257
316
|
return rows[0] ?? null;
|
|
@@ -267,13 +326,20 @@ export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
|
|
|
267
326
|
UPDATE "PlatformJob"
|
|
268
327
|
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
269
328
|
WHERE id = (
|
|
270
|
-
SELECT id FROM "PlatformJob"
|
|
329
|
+
SELECT id FROM "PlatformJob" p
|
|
271
330
|
WHERE status = 'QUEUED'
|
|
331
|
+
AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
|
|
272
332
|
AND (
|
|
273
333
|
type != 'DISCOVER'
|
|
274
334
|
OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
|
|
275
335
|
)
|
|
276
|
-
|
|
336
|
+
AND (
|
|
337
|
+
SELECT COUNT(*) FROM "PlatformJob" p2
|
|
338
|
+
WHERE p2.status = 'RUNNING'
|
|
339
|
+
AND p2."tenantId" = p."tenantId"
|
|
340
|
+
AND p2.inline = false
|
|
341
|
+
) < ${MAX_CONCURRENT_PER_TENANT}
|
|
342
|
+
ORDER BY p."priority" DESC, p."queuedAt" ASC
|
|
277
343
|
FOR UPDATE SKIP LOCKED
|
|
278
344
|
LIMIT 1
|
|
279
345
|
)
|
|
@@ -376,36 +442,77 @@ export async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
|
376
442
|
stopTimer();
|
|
377
443
|
crawlJobsTotal.inc({ type: job.type, status: 'failed' });
|
|
378
444
|
logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, '[job-worker] job threw uncaught error');
|
|
445
|
+
const errorMessage = String(err?.message ?? err);
|
|
446
|
+
const failureType = classifyFailure(err);
|
|
447
|
+
// Read current retryCount before incrementing so computeNextRetryAt uses the right base.
|
|
448
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { retryCount: true, result: true } }).catch(() => null);
|
|
449
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
450
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
379
451
|
await prismaAdmin.platformJob.update({
|
|
380
452
|
where: { id: job.id },
|
|
381
|
-
data: {
|
|
453
|
+
data: {
|
|
454
|
+
status: nextRetryAt ? 'RETRY_WAIT' : 'FAILED',
|
|
455
|
+
error: errorMessage,
|
|
456
|
+
finishedAt: new Date(),
|
|
457
|
+
heartbeatAt: null,
|
|
458
|
+
workerId: null,
|
|
459
|
+
nextRetryAt,
|
|
460
|
+
retryCount: { increment: 1 },
|
|
461
|
+
failureType,
|
|
462
|
+
// Store failureType in result JSON for observability (backward compat)
|
|
463
|
+
result: { ...(jobRow?.result as any ?? {}), failureType } as any,
|
|
464
|
+
},
|
|
382
465
|
}).catch(() => { });
|
|
466
|
+
// If fatal or exceeded retries — promote to DLQ immediately
|
|
467
|
+
if (!nextRetryAt || failureType === FailureType.FATAL) {
|
|
468
|
+
await prismaAdmin.platformJob.update({
|
|
469
|
+
where: { id: job.id },
|
|
470
|
+
data: { dlq: true, dlqAt: new Date(), dlqReason: errorMessage },
|
|
471
|
+
}).catch(() => { });
|
|
472
|
+
}
|
|
383
473
|
}
|
|
384
474
|
}
|
|
385
475
|
|
|
386
476
|
let workerLoopTimer: ReturnType<typeof setInterval> | null = null;
|
|
477
|
+
// GAP-12: signals the semaphore-based poll loop to exit gracefully.
|
|
478
|
+
let workerShuttingDown = false;
|
|
387
479
|
|
|
388
|
-
/** Re-promotes FAILED jobs to
|
|
480
|
+
/** Re-promotes FAILED jobs to RETRY_WAIT if they are eligible for retry based on per-type limits.
|
|
481
|
+
* RETRY_WAIT makes the waiting period visible in the UI. A separate sweep then promotes
|
|
482
|
+
* RETRY_WAIT → QUEUED once nextRetryAt <= NOW(). */
|
|
389
483
|
export async function retryEligibleFailedJobs(): Promise<void> {
|
|
484
|
+
// Legacy path: jobs that failed before failure-type classification was added.
|
|
485
|
+
// New failures go FAILED→RETRY_WAIT directly in the job error handler (dispatchClaimedJob).
|
|
486
|
+
// This only picks up old FAILED rows that have no nextRetryAt set yet.
|
|
487
|
+
// Phase 1: FAILED → RETRY_WAIT (GAP-07: visible waiting state)
|
|
390
488
|
const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
|
|
391
489
|
UPDATE "PlatformJob"
|
|
392
|
-
SET status = '
|
|
490
|
+
SET status = 'RETRY_WAIT',
|
|
393
491
|
"retryCount" = "retryCount" + 1,
|
|
394
492
|
"nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
|
|
395
493
|
"workerId" = NULL
|
|
396
494
|
WHERE status = 'FAILED'
|
|
397
495
|
AND dlq = false
|
|
398
|
-
AND "nextRetryAt"
|
|
399
|
-
AND "retryCount" <
|
|
400
|
-
WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
|
|
401
|
-
WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
|
|
402
|
-
WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
|
|
403
|
-
ELSE 1
|
|
404
|
-
END
|
|
496
|
+
AND "nextRetryAt" IS NULL
|
|
497
|
+
AND "retryCount" < 5
|
|
405
498
|
RETURNING id
|
|
406
499
|
`;
|
|
407
500
|
if (rows.length > 0) {
|
|
408
|
-
logger.info({ jobIds: rows.map((r: any) => r.id) }, '[job-worker]
|
|
501
|
+
logger.info({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] moved failed job(s) to RETRY_WAIT');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Phase 2: RETRY_WAIT → QUEUED when the wait period has elapsed (GAP-07)
|
|
505
|
+
const readyRows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
|
|
506
|
+
UPDATE "PlatformJob"
|
|
507
|
+
SET status = 'QUEUED',
|
|
508
|
+
"requeuedAt" = NOW()
|
|
509
|
+
WHERE status = 'RETRY_WAIT'
|
|
510
|
+
AND "nextRetryAt" <= NOW()
|
|
511
|
+
AND dlq = false
|
|
512
|
+
RETURNING id
|
|
513
|
+
`;
|
|
514
|
+
if (readyRows.length > 0) {
|
|
515
|
+
logger.info({ jobIds: readyRows.map((r: any) => r.id) }, '[job-worker] promoted RETRY_WAIT job(s) to QUEUED');
|
|
409
516
|
}
|
|
410
517
|
}
|
|
411
518
|
|
|
@@ -414,6 +521,7 @@ export async function promoteToDeadLetterQueue(): Promise<void> {
|
|
|
414
521
|
const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
|
|
415
522
|
UPDATE "PlatformJob"
|
|
416
523
|
SET dlq = true,
|
|
524
|
+
"dlqAt" = NOW(),
|
|
417
525
|
"dlqReason" = error
|
|
418
526
|
WHERE status = 'FAILED'
|
|
419
527
|
AND dlq = false
|
|
@@ -444,7 +552,8 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
|
|
|
444
552
|
const concurrency = opts?.concurrency ?? WORKER_CONCURRENCY;
|
|
445
553
|
logger.info({ workerId: WORKER_ID, pollIntervalMs, concurrency }, '[job-worker] starting');
|
|
446
554
|
|
|
447
|
-
|
|
555
|
+
// GAP-12: maintenance sweep stays on a fixed interval (unchanged).
|
|
556
|
+
const sweepTick = async () => {
|
|
448
557
|
try {
|
|
449
558
|
await retryEligibleFailedJobs();
|
|
450
559
|
await promoteToDeadLetterQueue();
|
|
@@ -452,33 +561,71 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
|
|
|
452
561
|
} catch (err) {
|
|
453
562
|
logger.error({ err: String(err) }, '[job-worker] stale/retry job sweep failed');
|
|
454
563
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
564
|
+
};
|
|
565
|
+
workerLoopTimer = setInterval(sweepTick, pollIntervalMs);
|
|
566
|
+
|
|
567
|
+
// GAP-12: single continuous poll loop — checks the semaphore before every
|
|
568
|
+
// claim so activeExecutions never exceeds WORKER_CONCURRENCY regardless of
|
|
569
|
+
// how long individual jobs take. Jobs are non-blocking: withSemaphore(...)
|
|
570
|
+
// is NOT awaited here, so the loop immediately loops back and can claim the
|
|
571
|
+
// next job while previous ones are still running.
|
|
572
|
+
//
|
|
573
|
+
// NOTE (GAP-20): requeueOrphanedJobs() is intentionally NOT called at startup here.
|
|
574
|
+
// Calling it at startup is unsafe in multi-replica deployments — replica A would steal
|
|
575
|
+
// RUNNING jobs from replica B (which is still alive and processing them), causing
|
|
576
|
+
// double-execution. The stale-job sweep (requeueStaleRunningJobs) handles recovery
|
|
577
|
+
// safely via heartbeat timeout. requeueOrphanedJobs() remains available for admin use.
|
|
578
|
+
workerShuttingDown = false;
|
|
579
|
+
|
|
580
|
+
const pollLoop = async () => {
|
|
581
|
+
void sweepTick(); // run one sweep immediately on startup
|
|
582
|
+
while (!workerShuttingDown) {
|
|
583
|
+
if (activeExecutions >= concurrency) {
|
|
584
|
+
// All slots busy — wait before checking again without hammering the DB.
|
|
585
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let paused = false;
|
|
590
|
+
try {
|
|
591
|
+
const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
|
|
592
|
+
paused = pauseRow?.value === 'true';
|
|
593
|
+
} catch {
|
|
594
|
+
// ignore — treat as unpaused
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (paused) {
|
|
458
598
|
logger.info('[job-worker] queue is paused — skipping claim');
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const job = await claimNextQueuedJob();
|
|
462
|
-
if (!job) break;
|
|
463
|
-
void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
|
|
464
|
-
}
|
|
599
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
600
|
+
continue;
|
|
465
601
|
}
|
|
466
|
-
|
|
467
|
-
|
|
602
|
+
|
|
603
|
+
let job: ClaimedJob | null = null;
|
|
604
|
+
try {
|
|
605
|
+
job = await claimNextQueuedJob();
|
|
606
|
+
} catch (err) {
|
|
607
|
+
logger.error({ err: String(err) }, '[job-worker] claim failed');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (!job) {
|
|
611
|
+
// Nothing queued — back off before polling again.
|
|
612
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Non-blocking: fire and forget into semaphore — poll loop continues immediately.
|
|
617
|
+
withSemaphore(() => dispatchClaimedJob(job!)).catch(err => {
|
|
618
|
+
logger.error({ err, jobId: job!.id }, '[worker] unhandled job error');
|
|
619
|
+
});
|
|
468
620
|
}
|
|
469
621
|
};
|
|
470
622
|
|
|
471
|
-
|
|
472
|
-
// On startup: immediately requeue jobs orphaned by the previous process instance.
|
|
473
|
-
// This gives instant recovery rather than waiting STALE_JOB_TIMEOUT_MINUTES (30 min).
|
|
474
|
-
requeueOrphanedJobs().catch((err) =>
|
|
475
|
-
logger.error({ err: String(err) }, '[job-worker] startup orphan sweep failed')
|
|
476
|
-
);
|
|
477
|
-
void tick();
|
|
623
|
+
void pollLoop();
|
|
478
624
|
}
|
|
479
625
|
|
|
480
626
|
/** Stops the poll loop. Mainly for tests. */
|
|
481
627
|
export function stopJobWorkerLoop(): void {
|
|
628
|
+
workerShuttingDown = true; // GAP-12: signal continuous poll loop to exit
|
|
482
629
|
if (workerLoopTimer) clearInterval(workerLoopTimer);
|
|
483
630
|
workerLoopTimer = null;
|
|
484
631
|
}
|
|
@@ -491,31 +638,50 @@ export async function createTrackedJob(
|
|
|
491
638
|
const name = makeJobName(type);
|
|
492
639
|
const job = await withTenant(config.tenantId, (tx) =>
|
|
493
640
|
tx.platformJob.create({
|
|
494
|
-
data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: 'RUNNING', startedAt: new Date(), config: config as any },
|
|
641
|
+
data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: 'RUNNING', startedAt: new Date(), inline: true, config: config as any },
|
|
495
642
|
})
|
|
496
643
|
) as { id: string };
|
|
497
644
|
return job.id;
|
|
498
645
|
}
|
|
499
646
|
|
|
500
647
|
/** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors.
|
|
501
|
-
*
|
|
648
|
+
* GAP-14/15: Uses failure-type classification for per-type backoff on FAILED. */
|
|
502
649
|
export async function finishTrackedJob(tenantId: string, jobId: string, error?: string): Promise<void> {
|
|
503
|
-
|
|
650
|
+
if (!error) {
|
|
651
|
+
await withTenant(tenantId, (tx) =>
|
|
652
|
+
tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', finishedAt: new Date(), progress: 100 } })
|
|
653
|
+
).catch(() => { });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const failureType = classifyFailure(error);
|
|
657
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: jobId }, select: { retryCount: true, result: true } }).catch(() => null);
|
|
658
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
659
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
504
660
|
await withTenant(tenantId, (tx) =>
|
|
505
661
|
tx.platformJob.update({
|
|
506
662
|
where: { id: jobId },
|
|
507
663
|
data: {
|
|
508
|
-
status:
|
|
664
|
+
status: nextRetryAt ? 'RETRY_WAIT' : 'FAILED',
|
|
509
665
|
finishedAt: new Date(),
|
|
510
|
-
|
|
666
|
+
error,
|
|
667
|
+
nextRetryAt,
|
|
668
|
+
retryCount: { increment: 1 },
|
|
669
|
+
failureType,
|
|
670
|
+
result: { ...(jobRow?.result as any ?? {}), failureType } as any,
|
|
511
671
|
},
|
|
512
672
|
})
|
|
513
673
|
).catch(() => { });
|
|
674
|
+
if (!nextRetryAt || failureType === FailureType.FATAL) {
|
|
675
|
+
await prismaAdmin.platformJob.update({
|
|
676
|
+
where: { id: jobId },
|
|
677
|
+
data: { dlq: true, dlqAt: new Date(), dlqReason: error },
|
|
678
|
+
}).catch(() => { });
|
|
679
|
+
}
|
|
514
680
|
}
|
|
515
681
|
|
|
516
682
|
/** Enqueue a generic platform job — returns the job ID immediately. */
|
|
517
683
|
export async function enqueueJob(type: string, config: any): Promise<string> {
|
|
518
|
-
const { tenantId, projectId } = config;
|
|
684
|
+
const { tenantId, projectId, submittedBy } = config;
|
|
519
685
|
|
|
520
686
|
const name = makeJobName(type);
|
|
521
687
|
const job = await withTenant(tenantId, (tx) =>
|
|
@@ -527,6 +693,7 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
|
|
|
527
693
|
name,
|
|
528
694
|
status: 'QUEUED',
|
|
529
695
|
config: config as any,
|
|
696
|
+
...(submittedBy ? { submittedBy } : {}),
|
|
530
697
|
},
|
|
531
698
|
})
|
|
532
699
|
) as { id: string };
|
|
@@ -580,7 +747,10 @@ export async function listJobs(tenantId: string, projectId: string) {
|
|
|
580
747
|
});
|
|
581
748
|
}
|
|
582
749
|
|
|
583
|
-
/** Cancel a RUNNING or QUEUED job.
|
|
750
|
+
/** Cancel a RUNNING or QUEUED job.
|
|
751
|
+
* Sets status to CANCEL_REQUESTED (GAP-08) so the worker's abort handler can
|
|
752
|
+
* detect the request and set CANCELLED + finishedAt after the underlying work stops.
|
|
753
|
+
* The in-process AbortController is also triggered for same-process jobs. */
|
|
584
754
|
export async function cancelJob(tenantId: string, jobId: string): Promise<void> {
|
|
585
755
|
// Abort in-memory controller if job is running in the same process
|
|
586
756
|
const controller = runningControllers.get(jobId);
|
|
@@ -591,7 +761,7 @@ export async function cancelJob(tenantId: string, jobId: string): Promise<void>
|
|
|
591
761
|
await withTenant(tenantId, (tx) =>
|
|
592
762
|
tx.platformJob.update({
|
|
593
763
|
where: { id: jobId },
|
|
594
|
-
data: { status: '
|
|
764
|
+
data: { status: 'CANCEL_REQUESTED' },
|
|
595
765
|
})
|
|
596
766
|
);
|
|
597
767
|
}
|
|
@@ -636,20 +806,21 @@ export async function updateJobProgress(tenantId: string, jobId: string, progres
|
|
|
636
806
|
try {
|
|
637
807
|
await withTenant(tenantId, async (tx) => {
|
|
638
808
|
// DB-flag cancellation: works for both inline and SQS backends.
|
|
639
|
-
// When a user cancels, status is set to
|
|
809
|
+
// When a user cancels, status is set to CANCEL_REQUESTED (GAP-08).
|
|
640
810
|
// This check fires at each progress update (every few seconds during crawl).
|
|
641
811
|
const current = await tx.platformJob.findUnique({
|
|
642
812
|
where: { id: jobId },
|
|
643
813
|
select: { status: true },
|
|
644
814
|
});
|
|
645
|
-
if (current?.status === 'CANCELLED') {
|
|
815
|
+
if (current?.status === 'CANCELLED' || current?.status === 'CANCEL_REQUESTED') {
|
|
646
816
|
runningControllers.get(jobId)?.abort();
|
|
647
817
|
runningControllers.delete(jobId);
|
|
648
818
|
return; // abort signal propagates; crawlProject exits on next signal check
|
|
649
819
|
}
|
|
650
820
|
await tx.platformJob.update({
|
|
651
821
|
where: { id: jobId },
|
|
652
|
-
|
|
822
|
+
// GAP-09: write currentStep as a proper column in addition to result.step (backward compat)
|
|
823
|
+
data: { progress, currentStep: step, result: { step } as any, heartbeatAt: new Date() },
|
|
653
824
|
});
|
|
654
825
|
});
|
|
655
826
|
} catch { /* non-fatal — progress updates best-effort */ }
|
|
@@ -663,7 +834,7 @@ async function heartbeatJob(tenantId: string, jobId: string) {
|
|
|
663
834
|
where: { id: jobId },
|
|
664
835
|
select: { status: true },
|
|
665
836
|
});
|
|
666
|
-
if (current?.status === 'CANCELLED') {
|
|
837
|
+
if (current?.status === 'CANCELLED' || current?.status === 'CANCEL_REQUESTED') {
|
|
667
838
|
runningControllers.get(jobId)?.abort();
|
|
668
839
|
runningControllers.delete(jobId);
|
|
669
840
|
return;
|
|
@@ -800,6 +971,19 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
800
971
|
storageConfig: storageConfigForCrawl,
|
|
801
972
|
onCheckpoint: async (visitedUrls, pendingQueue) => {
|
|
802
973
|
jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
|
|
974
|
+
// GAP-19: configurable checkpoint interval; GAP-04: persist to DB for crash recovery
|
|
975
|
+
const CHECKPOINT_INTERVAL = parseInt(process.env.CRAWL_CHECKPOINT_INTERVAL ?? '10');
|
|
976
|
+
if (visitedUrls.length % CHECKPOINT_INTERVAL === 0) {
|
|
977
|
+
try {
|
|
978
|
+
await prismaAdmin.platformJob.update({
|
|
979
|
+
where: { id: jobId },
|
|
980
|
+
data: {
|
|
981
|
+
checkpoint: { visitedUrls, pendingQueue } as any,
|
|
982
|
+
checkpointAt: new Date(),
|
|
983
|
+
},
|
|
984
|
+
});
|
|
985
|
+
} catch { /* non-fatal */ }
|
|
986
|
+
}
|
|
803
987
|
},
|
|
804
988
|
}, onProgress);
|
|
805
989
|
jobCheckpoints.delete(jobId);
|
|
@@ -1482,7 +1666,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1482
1666
|
|
|
1483
1667
|
let parsed: { cases: any[] } = { cases: [] };
|
|
1484
1668
|
try {
|
|
1485
|
-
const reqSeed = deterministicSeed(
|
|
1669
|
+
const reqSeed = deterministicSeed(`req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
|
|
1486
1670
|
const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
|
|
1487
1671
|
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
|
|
1488
1672
|
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
@@ -1789,6 +1973,20 @@ export async function processDataRetentionPurge(): Promise<void> {
|
|
|
1789
1973
|
},
|
|
1790
1974
|
});
|
|
1791
1975
|
logger.info({ deleted: sessionResult.count }, '[data-retention] AuthSession purged');
|
|
1976
|
+
|
|
1977
|
+
// PlatformJob: delete completed jobs older than 90 days (GAP-24)
|
|
1978
|
+
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1979
|
+
const deletedJobs = await prismaAdmin.platformJob.deleteMany({
|
|
1980
|
+
where: {
|
|
1981
|
+
status: { in: ['DONE', 'FAILED'] },
|
|
1982
|
+
dlq: false,
|
|
1983
|
+
inline: false,
|
|
1984
|
+
finishedAt: { lt: ninetyDaysAgo },
|
|
1985
|
+
},
|
|
1986
|
+
});
|
|
1987
|
+
if (deletedJobs.count > 0) {
|
|
1988
|
+
logger.info({ count: deletedJobs.count }, '[data-retention] deleted old completed jobs');
|
|
1989
|
+
}
|
|
1792
1990
|
}
|
|
1793
1991
|
|
|
1794
1992
|
// ── URL Discovery Job ─────────────────────────────────────────────────────────
|
|
@@ -2341,8 +2539,13 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
|
|
|
2341
2539
|
const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null);
|
|
2342
2540
|
const deviceProfiles: string[] = (crawlCfg as any)?.deviceProfiles ?? ['DESKTOP'];
|
|
2343
2541
|
|
|
2542
|
+
const existingAiCases: number = await withTenant(tenantId, (tx) =>
|
|
2543
|
+
(tx as any).testCase.count({ where: { tenantId, screenId, source: 'AI_GENERATED' } })
|
|
2544
|
+
);
|
|
2545
|
+
const generationMode = existingAiCases > 0 ? 'replace' : undefined;
|
|
2546
|
+
|
|
2344
2547
|
for (const viewport of deviceProfiles) {
|
|
2345
|
-
await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport });
|
|
2548
|
+
await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport, ...(generationMode ? { mode: generationMode } : {}) });
|
|
2346
2549
|
}
|
|
2347
2550
|
|
|
2348
2551
|
await updateJobProgress(tenantId, jobId, 80, 'Test cases generated');
|
|
@@ -2977,13 +3180,111 @@ async function processGenerateScriptJob(jobId: string, config: any): Promise<voi
|
|
|
2977
3180
|
}
|
|
2978
3181
|
}
|
|
2979
3182
|
|
|
3183
|
+
function queueImportedArtifactSkillRefresh(tenantId: string, projectId: string, reason: string): void {
|
|
3184
|
+
setImmediate(() => {
|
|
3185
|
+
import('@detiq/agents')
|
|
3186
|
+
.then((agentsModule) => {
|
|
3187
|
+
const agents = agentsModule as any;
|
|
3188
|
+
return Promise.allSettled([
|
|
3189
|
+
agents.refreshArtifactKnowledgeSkillFile(tenantId, projectId),
|
|
3190
|
+
agents.refreshRequirementsCoverageSkillFile(tenantId, projectId),
|
|
3191
|
+
]);
|
|
3192
|
+
})
|
|
3193
|
+
.then((results) => {
|
|
3194
|
+
results?.forEach((result) => {
|
|
3195
|
+
if (result.status === 'rejected') {
|
|
3196
|
+
console.warn(`[artifacts] skill refresh failed after ${reason}:`, result.reason?.message ?? result.reason);
|
|
3197
|
+
}
|
|
3198
|
+
});
|
|
3199
|
+
})
|
|
3200
|
+
.catch((e) => console.warn(`[artifacts] skill refresh failed after ${reason}:`, e?.message ?? e));
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
|
|
2980
3204
|
// Registers the type→processor dispatch table used by the durable worker (both the
|
|
2981
3205
|
// enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
|
|
2982
3206
|
// bottom so it can reference every process*Job function by name; function
|
|
2983
3207
|
// declarations are hoisted, so this could technically live anywhere in the file,
|
|
2984
3208
|
// but keeping it here alongside the last definition keeps the mapping easy to audit
|
|
2985
3209
|
// against the actual functions above.
|
|
3210
|
+
const CONTENT_DETECTED_DOC_TYPES = new Set(['FUNCTIONIZE_TC', 'SELENIUM_SCRIPT', 'ENV_CONFIG']);
|
|
3211
|
+
const AI_DOC_TYPES = new Set([
|
|
3212
|
+
'REQUIREMENTS', 'USER_STORY', 'TEST_CASES', 'TEST_DATA', 'AUTOMATION_SCRIPT', 'API_SPEC', 'ARCHITECTURE', 'REPORT', 'OTHER',
|
|
3213
|
+
'SPREADSHEET', 'DATASHEET',
|
|
3214
|
+
]);
|
|
3215
|
+
const AI_DOC_TYPE_ALIASES: Record<string, string> = {
|
|
3216
|
+
REQUIREMENT: 'REQUIREMENTS', SPEC: 'REQUIREMENTS', SPECIFICATION: 'REQUIREMENTS',
|
|
3217
|
+
USER_STORIES: 'USER_STORY', STORY: 'USER_STORY',
|
|
3218
|
+
TEST_CASE: 'TEST_CASES', TESTCASES: 'TEST_CASES',
|
|
3219
|
+
TESTDATA: 'TEST_DATA', SCRIPT: 'AUTOMATION_SCRIPT', AUTOMATION: 'AUTOMATION_SCRIPT',
|
|
3220
|
+
};
|
|
3221
|
+
function normalizeAiDocType(raw: unknown): string {
|
|
3222
|
+
const key = String(raw ?? '').trim().toUpperCase().replace(/[\s-]+/g, '_');
|
|
3223
|
+
const mapped = AI_DOC_TYPE_ALIASES[key] ?? key;
|
|
3224
|
+
return AI_DOC_TYPES.has(mapped) ? mapped : 'OTHER';
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
async function processArtifactIntelJob(jobId: string, config: any) {
|
|
3228
|
+
const { tenantId, projectId, artifactId, artifactName, isReprocess } = config;
|
|
3229
|
+
try {
|
|
3230
|
+
await updateJobProgress(tenantId, jobId, 10, 'Downloading artifact content');
|
|
3231
|
+
|
|
3232
|
+
const artifact = await withTenant(tenantId, (tx) =>
|
|
3233
|
+
(tx as any).projectArtifact.findUnique({ where: { id: artifactId }, select: { storageKey: true, docType: true } })
|
|
3234
|
+
) as any;
|
|
3235
|
+
|
|
3236
|
+
if (!artifact || !artifact.storageKey) {
|
|
3237
|
+
await updateJobProgress(tenantId, jobId, 100, 'Artifact deleted or missing content, skipping.');
|
|
3238
|
+
await withTenant(tenantId, (tx) =>
|
|
3239
|
+
(tx as any).platformJob.update({ where: { id: jobId }, data: { status: 'DONE', finishedAt: new Date() } })
|
|
3240
|
+
);
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
const storageCfg = await getTenantStorageConfig(tenantId);
|
|
3245
|
+
if (!storageCfg) {
|
|
3246
|
+
throw new Error('Tenant storage configuration missing.');
|
|
3247
|
+
}
|
|
3248
|
+
const contentBuffer = await downloadBufferFromTenantStorage(storageCfg, artifact.storageKey);
|
|
3249
|
+
const content = contentBuffer.toString('utf8');
|
|
3250
|
+
|
|
3251
|
+
await updateJobProgress(tenantId, jobId, 40, 'Generating intelligence');
|
|
3252
|
+
|
|
3253
|
+
const intel = await generateArtifactIntelligence(tenantId, projectId, artifactId, content, artifactName, jobId);
|
|
3254
|
+
|
|
3255
|
+
const finalDocType = artifact.docType && CONTENT_DETECTED_DOC_TYPES.has(artifact.docType)
|
|
3256
|
+
? artifact.docType
|
|
3257
|
+
: normalizeAiDocType(intel.docType);
|
|
3258
|
+
|
|
3259
|
+
if (finalDocType !== intel.docType) {
|
|
3260
|
+
await withTenant(tenantId, (tx) =>
|
|
3261
|
+
(tx as any).projectArtifact.update({ where: { id: artifactId }, data: { docType: finalDocType } })
|
|
3262
|
+
).catch((err: any) => console.warn(`[artifact-intel] docType fix-up failed for "${artifactName}":`, err?.message));
|
|
3263
|
+
intel.docType = finalDocType;
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
await updateJobProgress(tenantId, jobId, 90, 'Saving results');
|
|
3267
|
+
|
|
3268
|
+
await withTenant(tenantId, (tx) =>
|
|
3269
|
+
(tx as any).platformJob.update({ where: { id: jobId }, data: { result: { artifactId, artifactName, summary: intel.summary, keywords: intel.keywords, docType: intel.docType }, status: 'DONE', finishedAt: new Date(), progress: 100 } })
|
|
3270
|
+
);
|
|
3271
|
+
|
|
3272
|
+
queueImportedArtifactSkillRefresh(tenantId, projectId, isReprocess ? 'artifact-reprocess' : 'artifact-intel');
|
|
3273
|
+
} catch (err: any) {
|
|
3274
|
+
const msg = String(err?.message ?? err);
|
|
3275
|
+
logger.error({ jobId, err: msg }, '[artifact-intel] failed');
|
|
3276
|
+
await withTenant(tenantId, (tx) =>
|
|
3277
|
+
(tx as any).projectArtifact.update({ where: { id: artifactId }, data: { status: 'ERROR', summary: 'AI analysis failed' } })
|
|
3278
|
+
).catch(() => { });
|
|
3279
|
+
await withTenant(tenantId, (tx) =>
|
|
3280
|
+
(tx as any).platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3281
|
+
).catch(() => { });
|
|
3282
|
+
queueImportedArtifactSkillRefresh(tenantId, projectId, 'artifact-intel-failed');
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
|
|
2986
3286
|
jobDispatchTable = {
|
|
3287
|
+
ARTIFACT_INTEL: processArtifactIntelJob,
|
|
2987
3288
|
CRAWL: processJob,
|
|
2988
3289
|
CAPTURE_STEPS: processCaptureStepsJob,
|
|
2989
3290
|
DISCOVER: processDiscoverJob,
|
|
@@ -3506,8 +3807,6 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
|
|
|
3506
3807
|
) as any;
|
|
3507
3808
|
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
3508
3809
|
|
|
3509
|
-
const { decrypt } = await import('@detiq/core');
|
|
3510
|
-
|
|
3511
3810
|
const {
|
|
3512
3811
|
generateScriptAligned, deriveRepoTargetPath, validateGeneratedScript,
|
|
3513
3812
|
mapWithConcurrency, SCRIPT_GEN_CONCURRENCY,
|
|
@@ -3772,7 +4071,7 @@ function buildPRBody(scripts: any[], projectId: string): string {
|
|
|
3772
4071
|
// ── NEW_PAGES_PIPELINE — post-crawl: detect new screens, generate tests + scripts ──
|
|
3773
4072
|
|
|
3774
4073
|
async function processNewPagesPipelineJob(jobId: string, config: any) {
|
|
3775
|
-
const { tenantId, projectId, crawlJobId, repoConnectionId, autoPR = false } = config;
|
|
4074
|
+
const { tenantId, projectId, crawlJobId: _crawlJobId, repoConnectionId, autoPR = false } = config;
|
|
3776
4075
|
try {
|
|
3777
4076
|
await updateJobProgress(tenantId, jobId, 5, 'Detecting new screens');
|
|
3778
4077
|
|