@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/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
- // Notification/Membership both have FORCE ROW LEVEL SECURITY — a bare
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 { AppBrain, getProjectStorageConfig, buildArtifactKey, uploadArtifact, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from '@detiq/app-brain';
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
- ORDER BY "queuedAt" ASC
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: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) },
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 QUEUED if they are eligible for retry based on per-type limits. */
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 = 'QUEUED',
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" <= NOW()
399
- AND "retryCount" < CASE
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] requeued failed job(s) for retry');
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
- const tick = async () => {
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
- try {
456
- const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
457
- if (pauseRow?.value === 'true') {
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
- } else {
460
- for (let i = 0; i < concurrency; i++) {
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
- } catch (err) {
467
- logger.error({ err: String(err) }, '[job-worker] claim loop failed');
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
- workerLoopTimer = setInterval(tick, pollIntervalMs);
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
- * Gap 30: When marking FAILED, sets nextRetryAt (1 min) so the retry worker picks it up promptly. */
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
- const nextRetryAt = error ? new Date(Date.now() + 60_000) : undefined;
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: error ? 'FAILED' : 'DONE',
664
+ status: nextRetryAt ? 'RETRY_WAIT' : 'FAILED',
509
665
  finishedAt: new Date(),
510
- ...(error ? { error, nextRetryAt } : { progress: 100 }),
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. Aborts the in-process crawl and marks DB CANCELLED. */
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: 'CANCELLED', finishedAt: new Date(), heartbeatAt: null, workerId: null },
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 'CANCELLED' in DB.
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
- data: { progress, result: { step } as any, heartbeatAt: new Date() },
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(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
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