@detiq/crawler 1.1.28 → 1.2.0

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
@@ -107,6 +107,74 @@ function computeNextRetryAt(failureType: FailureType, retryCount: number): Date
107
107
  return new Date(Date.now() + delayMs);
108
108
  }
109
109
 
110
+ /**
111
+ * Centralized failure recorder for all PlatformJobs.
112
+ * - Classifies error into FailureType (TRANSIENT, INFRASTRUCTURE, LOGICAL, FATAL)
113
+ * - Computes exponential backoff per failure classification
114
+ * - If FATAL or retries exhausted: marks status='FAILED', dlq=true, dlqAt=NOW(), dlqReason=error
115
+ * - If retryable: marks status='RETRY_WAIT', nextRetryAt=NOW() + backoff, retryCount=retryCount+1
116
+ * - Persists checkpoint and checkpointAt if checkpointData provided
117
+ * - Updates failureType on the row and in result.failureType
118
+ */
119
+ export async function recordJobFailure(
120
+ tenantId: string | null | undefined,
121
+ jobId: string,
122
+ err: unknown,
123
+ checkpointData?: any,
124
+ options?: { isAborted?: boolean }
125
+ ): Promise<void> {
126
+ const errorMessage = err instanceof Error ? err.message : String(err);
127
+ const aborted = options?.isAborted ?? (errorMessage.includes('CRAWL_ABORTED') || errorMessage.includes('Stopped by user'));
128
+
129
+ if (aborted) {
130
+ await prismaAdmin.platformJob.update({
131
+ where: { id: jobId },
132
+ data: {
133
+ status: 'CANCELLED',
134
+ error: 'Stopped by user',
135
+ finishedAt: new Date(),
136
+ heartbeatAt: null,
137
+ workerId: null,
138
+ ...(checkpointData ? { checkpoint: checkpointData, checkpointAt: new Date() } : {}),
139
+ },
140
+ }).catch(() => {});
141
+ return;
142
+ }
143
+
144
+ const failureType = classifyFailure(err);
145
+ const jobRow = await prismaAdmin.platformJob.findUnique({
146
+ where: { id: jobId },
147
+ select: { retryCount: true, result: true }
148
+ }).catch(() => null);
149
+
150
+ const currentRetry = jobRow?.retryCount ?? 0;
151
+ const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
152
+ const isFatal = !nextRetryAt || failureType === FailureType.FATAL;
153
+
154
+ await prismaAdmin.platformJob.update({
155
+ where: { id: jobId },
156
+ data: {
157
+ status: isFatal ? 'FAILED' : 'RETRY_WAIT',
158
+ error: errorMessage.slice(0, 2000),
159
+ finishedAt: new Date(),
160
+ heartbeatAt: null,
161
+ workerId: null,
162
+ nextRetryAt,
163
+ retryCount: { increment: 1 },
164
+ failureType,
165
+ dlq: isFatal,
166
+ dlqAt: isFatal ? new Date() : null,
167
+ dlqReason: isFatal ? errorMessage.slice(0, 1000) : null,
168
+ ...(checkpointData ? { checkpoint: checkpointData, checkpointAt: new Date() } : {}),
169
+ result: {
170
+ ...(jobRow?.result as any ?? {}),
171
+ failureType,
172
+ ...(checkpointData ? { checkpoint: checkpointData } : {})
173
+ } as any,
174
+ },
175
+ }).catch((e) => logger.error({ err: e, jobId }, '[job-queue] failed to record job failure'));
176
+ }
177
+
110
178
  // ────────────────────────────────────────────────────────────────────────────────
111
179
 
112
180
  // RFC 6238 TOTP — no external dependency, uses Node built-in crypto.
@@ -362,7 +430,7 @@ export async function requeueStaleRunningJobs(): Promise<void> {
362
430
  error = 'API process was restarted while this job was running — please retry from the UI.'
363
431
  WHERE status = 'RUNNING'
364
432
  AND type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
365
- AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
433
+ AND COALESCE("heartbeatAt", "startedAt") < (NOW() AT TIME ZONE 'UTC') - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
366
434
  RETURNING id
367
435
  `;
368
436
  if (failedRows.length > 0) {
@@ -381,7 +449,7 @@ export async function requeueStaleRunningJobs(): Promise<void> {
381
449
  )
382
450
  WHERE status = 'RUNNING'
383
451
  AND type NOT IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
384
- AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
452
+ AND COALESCE("heartbeatAt", "startedAt") < (NOW() AT TIME ZONE 'UTC') - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
385
453
  RETURNING id
386
454
  `;
387
455
  if (rows.length > 0) {
@@ -437,39 +505,12 @@ export async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
437
505
  // rethrowing (see this function's doc comment). Read back the actual
438
506
  // final status so the metric reflects reality, not just "didn't throw".
439
507
  const finished = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { status: true } }).catch(() => null);
440
- crawlJobsTotal.inc({ type: job.type, status: finished?.status === 'FAILED' ? 'failed' : 'success' });
508
+ crawlJobsTotal.inc({ type: job.type, status: (finished?.status === 'FAILED' || finished?.status === 'RETRY_WAIT') ? 'failed' : 'success' });
441
509
  } catch (err: any) {
442
510
  stopTimer();
443
511
  crawlJobsTotal.inc({ type: job.type, status: 'failed' });
444
512
  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);
451
- await prismaAdmin.platformJob.update({
452
- where: { id: job.id },
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
- },
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
- }
513
+ await recordJobFailure(job.tenantId, job.id, err);
473
514
  }
474
515
  }
475
516
 
@@ -507,7 +548,7 @@ export async function retryEligibleFailedJobs(): Promise<void> {
507
548
  SET status = 'QUEUED',
508
549
  "requeuedAt" = NOW()
509
550
  WHERE status = 'RETRY_WAIT'
510
- AND "nextRetryAt" <= NOW()
551
+ AND "nextRetryAt" <= (NOW() AT TIME ZONE 'UTC')
511
552
  AND dlq = false
512
553
  RETURNING id
513
554
  `;
@@ -781,10 +822,10 @@ const jobCheckpoints = new Map<string, { visitedUrls: string[]; pendingQueue: st
781
822
  export async function resumeJob(tenantId: string, jobId: string): Promise<string> {
782
823
  const job = await withTenant(tenantId, (tx) =>
783
824
  tx.platformJob.findUnique({ where: { id: jobId } })
784
- ) as { type: string; config: any; result: any } | null;
825
+ ) as { type: string; config: any; result: any; checkpoint?: any } | null;
785
826
  if (!job) throw new Error('Job not found');
786
827
 
787
- const checkpoint = (job.result as any)?.checkpoint;
828
+ const checkpoint = (job as any).checkpoint ?? (job.result as any)?.checkpoint;
788
829
  const visitedUrls: string[] = checkpoint?.visitedUrls ?? [];
789
830
  const pendingQueue: string[] = checkpoint?.pendingQueue ?? [];
790
831
 
@@ -796,8 +837,8 @@ export async function resumeJob(tenantId: string, jobId: string): Promise<string
796
837
  return enqueueJob(job.type, {
797
838
  ...(job.config as any),
798
839
  tenantId,
799
- skipUrls: visitedUrls.length > 0 ? visitedUrls : undefined,
800
- resumeUrls: pendingQueue.length > 0 ? pendingQueue : undefined,
840
+ skipUrls: [...((job.config as any)?.skipUrls ?? []), ...visitedUrls],
841
+ startUrls: pendingQueue.length > 0 ? pendingQueue : (job.config as any)?.startUrls,
801
842
  });
802
843
  }
803
844
 
@@ -1225,18 +1266,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
1225
1266
  }).catch((enqErr: any) => logger.warn({ err: enqErr?.message }, '[crawl] auth-failure auto-segment enqueue failed (non-fatal)'));
1226
1267
  }
1227
1268
 
1228
- await withTenant(tenantId, (tx) =>
1229
- tx.platformJob.update({
1230
- where: { id: jobId },
1231
- data: {
1232
- status: aborted ? 'CANCELLED' : 'FAILED',
1233
- error: aborted ? 'Stopped by user' : String(err),
1234
- finishedAt: new Date(),
1235
- ...(aborted ? {} : { nextRetryAt: new Date(Date.now() + 60_000) }),
1236
- ...(checkpoint ? { result: { checkpoint } as any } : {}),
1237
- },
1238
- })
1239
- );
1269
+ await recordJobFailure(tenantId, jobId, err, checkpoint, { isAborted: aborted });
1240
1270
 
1241
1271
  // Notify on crawl failure (fire-and-forget)
1242
1272
  if (!aborted) {
@@ -1316,9 +1346,7 @@ async function processApiTestsJob(jobId: string, config: any) {
1316
1346
  );
1317
1347
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1318
1348
  } catch (err: any) {
1319
- await withTenant(tenantId, (tx) =>
1320
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
1321
- );
1349
+ await recordJobFailure(tenantId, jobId, err);
1322
1350
  }
1323
1351
  }
1324
1352
 
@@ -1347,9 +1375,7 @@ async function processSecurityTestsJob(jobId: string, config: any) {
1347
1375
  );
1348
1376
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1349
1377
  } catch (err: any) {
1350
- await withTenant(tenantId, (tx) =>
1351
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
1352
- );
1378
+ await recordJobFailure(tenantId, jobId, err);
1353
1379
  }
1354
1380
  }
1355
1381
 
@@ -1378,9 +1404,7 @@ async function processIntegrationTestsJob(jobId: string, config: any) {
1378
1404
  );
1379
1405
  aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
1380
1406
  } catch (err: any) {
1381
- await withTenant(tenantId, (tx) =>
1382
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
1383
- );
1407
+ await recordJobFailure(tenantId, jobId, err);
1384
1408
  }
1385
1409
  }
1386
1410
 
@@ -1505,9 +1529,7 @@ async function processFigmaImportJob(jobId: string, config: any) {
1505
1529
  });
1506
1530
 
1507
1531
  } catch (err: any) {
1508
- await withTenant(tenantId, (tx) =>
1509
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
1510
- );
1532
+ await recordJobFailure(tenantId, jobId, err);
1511
1533
  }
1512
1534
  }
1513
1535
 
@@ -1607,9 +1629,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1607
1629
  }
1608
1630
  });
1609
1631
  } catch (err: any) {
1610
- await withTenant(tenantId, (tx) =>
1611
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
1612
- ).catch(() => { });
1632
+ await recordJobFailure(tenantId, jobId, err);
1613
1633
  }
1614
1634
  }
1615
1635
 
@@ -1622,7 +1642,7 @@ async function processGenerateTestsFromReqsJob(jobId: string, config: any) {
1622
1642
  const llmCfg = await getTaskAgentLLMConfig(tenantId, 'testGeneration');
1623
1643
 
1624
1644
  const all = await AppBrain.listRequirements(tenantId, projectId, {}) as any[];
1625
- const reqs = reqIds?.length
1645
+ let reqs = reqIds?.length
1626
1646
  ? all.filter((r: any) => reqIds.includes(r.reqId))
1627
1647
  : all.filter((r: any) => r.status === 'unbuilt' || r.status === 'pending');
1628
1648
 
@@ -1646,75 +1666,107 @@ async function processGenerateTestsFromReqsJob(jobId: string, config: any) {
1646
1666
  resolvedSuiteId = suite.id;
1647
1667
  }
1648
1668
 
1669
+ // Resume from checkpoint if present
1670
+ const existingJob = await prismaAdmin.platformJob.findUnique({
1671
+ where: { id: jobId },
1672
+ select: { checkpoint: true },
1673
+ }).catch(() => null);
1674
+ const completedReqIds = new Set<string>((existingJob?.checkpoint as any)?.completedReqIds ?? []);
1675
+ if (completedReqIds.size > 0) {
1676
+ reqs = reqs.filter((r: any) => !completedReqIds.has(r.reqId));
1677
+ }
1678
+
1649
1679
  await updateJobProgress(tenantId, jobId, 20, `Generating tests for ${reqs.length} requirements`);
1650
1680
 
1651
- const reqBlock = reqs.map((r: any) => [
1652
- `## ${r.reqId}: ${r.title}`,
1653
- `Priority: ${r.priority} | Type: ${r.type}`,
1654
- r.acceptanceCriteria?.length ? `Acceptance Criteria:\n${r.acceptanceCriteria.map((c: string) => `- ${c}`).join('\n')}` : '',
1655
- r.businessRules?.length ? `Business Rules:\n${r.businessRules.map((b: string) => `- ${b}`).join('\n')}` : '',
1656
- ].filter(Boolean).join('\n')).join('\n\n');
1681
+ 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']);
1682
+ const VALID_PRIORITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
1657
1683
 
1658
- const system = `You are ZeTa-AI's test case generator. Convert requirements into structured test cases.
1684
+ // Process requirements in chunks of 5 with checkpointing
1685
+ const CHUNK_SIZE = 5;
1686
+ let totalCreated = (existingJob?.checkpoint as any)?.createdCount ?? 0;
1687
+ let totalSkipped = (existingJob?.checkpoint as any)?.skippedCount ?? 0;
1688
+ const finishedReqIds: string[] = Array.from(completedReqIds);
1689
+
1690
+ for (let i = 0; i < reqs.length; i += CHUNK_SIZE) {
1691
+ const chunk = reqs.slice(i, i + CHUNK_SIZE);
1692
+ const reqBlock = chunk.map((r: any) => [
1693
+ `## ${r.reqId}: ${r.title}`,
1694
+ `Priority: ${r.priority} | Type: ${r.type}`,
1695
+ r.acceptanceCriteria?.length ? `Acceptance Criteria:\n${r.acceptanceCriteria.map((c: string) => `- ${c}`).join('\n')}` : '',
1696
+ r.businessRules?.length ? `Business Rules:\n${r.businessRules.map((b: string) => `- ${b}`).join('\n')}` : '',
1697
+ ].filter(Boolean).join('\n')).join('\n\n');
1698
+
1699
+ const system = `You are ZeTa-AI's test case generator. Convert requirements into structured test cases.
1659
1700
  Return ONLY valid JSON: { "cases": [ { "title": string, "kind": string, "priority": string, "preconditions": string, "steps": [{"action": string, "data": string, "expected": string}], "expectedResult": string, "tags": string[], "reqRef": string } ] }
1660
1701
  Kind must be one of: POSITIVE, NEGATIVE, EDGE, SECURITY, ACCESSIBILITY, INTEGRATION, E2E, SMOKE, REGRESSION.
1661
1702
  Priority must be one of: CRITICAL, HIGH, MEDIUM, LOW.
1662
1703
  Generate 1-3 test cases per requirement (positive + negative + edge if applicable).
1663
1704
  Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1664
1705
 
1665
- const user = `Generate test cases for these requirements:\n\n${reqBlock}`;
1706
+ const user = `Generate test cases for these requirements:\n\n${reqBlock}`;
1666
1707
 
1667
- let parsed: { cases: any[] } = { cases: [] };
1668
- try {
1669
- const reqSeed = deterministicSeed(`req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
1670
- const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
1671
- if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
1672
- const json = text.match(/\{[\s\S]*\}/)?.[0];
1673
- if (json) parsed = JSON.parse(json);
1674
- } catch (e: any) {
1675
- throw new Error('AI failed to generate test cases: ' + (e.message ?? 'unknown'));
1676
- }
1708
+ let parsed: { cases: any[] } = { cases: [] };
1709
+ try {
1710
+ const reqSeed = deterministicSeed(`req-cases:${chunk.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
1711
+ const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
1712
+ if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
1713
+ const json = text.match(/\{[\s\S]*\}/)?.[0];
1714
+ if (json) parsed = JSON.parse(json);
1715
+ } catch (e: any) {
1716
+ throw new Error('AI failed to generate test cases: ' + (e.message ?? 'unknown'));
1717
+ }
1718
+
1719
+ const normalized = (parsed.cases ?? []).map((c: any) => ({
1720
+ ref: c.reqRef ?? null,
1721
+ traceTo: c.reqRef ?? null,
1722
+ title: c.title || 'Untitled',
1723
+ kind: VALID_KINDS.has((c.kind ?? '').toUpperCase()) ? c.kind.toUpperCase() : 'POSITIVE',
1724
+ priority: VALID_PRIORITIES.has((c.priority ?? '').toUpperCase()) ? c.priority.toUpperCase() : 'MEDIUM',
1725
+ preconditions: c.preconditions ?? '',
1726
+ steps: Array.isArray(c.steps) ? c.steps : [],
1727
+ expectedResult: c.expectedResult ?? '',
1728
+ tags: Array.isArray(c.tags) ? c.tags : [],
1729
+ }));
1677
1730
 
1678
- await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
1731
+ const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
1732
+ tenantId, projectId, resolvedSuiteId, normalized, { duplicateMode: 'skip' }
1733
+ );
1734
+ totalCreated += created.length;
1735
+ totalSkipped += (skipped ?? 0);
1679
1736
 
1680
- 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']);
1681
- const VALID_PRIORITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
1682
- const normalized = (parsed.cases ?? []).map((c: any) => ({
1683
- ref: c.reqRef ?? null,
1684
- traceTo: c.reqRef ?? null,
1685
- title: c.title || 'Untitled',
1686
- kind: VALID_KINDS.has((c.kind ?? '').toUpperCase()) ? c.kind.toUpperCase() : 'POSITIVE',
1687
- priority: VALID_PRIORITIES.has((c.priority ?? '').toUpperCase()) ? c.priority.toUpperCase() : 'MEDIUM',
1688
- preconditions: c.preconditions ?? '',
1689
- steps: Array.isArray(c.steps) ? c.steps : [],
1690
- expectedResult: c.expectedResult ?? '',
1691
- tags: Array.isArray(c.tags) ? c.tags : [],
1692
- }));
1737
+ // Mark processed requirements as covered
1738
+ const coveredReqIds = new Set(normalized.map((c: any) => c.ref).filter(Boolean));
1739
+ await Promise.all(
1740
+ chunk
1741
+ .filter((r: any) => coveredReqIds.has(r.reqId))
1742
+ .map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => { }))
1743
+ );
1693
1744
 
1694
- const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
1695
- tenantId, projectId, resolvedSuiteId, normalized, { duplicateMode: 'skip' }
1696
- );
1745
+ for (const r of chunk) finishedReqIds.push(r.reqId);
1697
1746
 
1698
- // Mark requirements as covered
1699
- const coveredReqIds = new Set(normalized.map((c: any) => c.ref).filter(Boolean));
1700
- await Promise.all(
1701
- reqs
1702
- .filter((r: any) => coveredReqIds.has(r.reqId))
1703
- .map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => { }))
1704
- );
1747
+ // Save checkpoint after chunk
1748
+ await prismaAdmin.platformJob.update({
1749
+ where: { id: jobId },
1750
+ data: {
1751
+ checkpoint: { completedReqIds: finishedReqIds, createdCount: totalCreated, skippedCount: totalSkipped } as any,
1752
+ checkpointAt: new Date(),
1753
+ },
1754
+ }).catch(() => {});
1755
+
1756
+ const progressPct = Math.round(20 + ((i + chunk.length) / reqs.length) * 75);
1757
+ await updateJobProgress(tenantId, jobId, progressPct, `Processed ${finishedReqIds.length}/${reqs.length} requirements (${totalCreated} cases generated)`);
1758
+ }
1705
1759
 
1706
1760
  await withTenant(tenantId, (tx) =>
1707
1761
  tx.platformJob.update({
1708
1762
  where: { id: jobId },
1709
- data: { status: 'DONE', finishedAt: new Date(), result: { created: created.length, skipped, suiteId: resolvedSuiteId } as any, progress: 100 },
1763
+ data: { status: 'DONE', finishedAt: new Date(), result: { created: totalCreated, skipped: totalSkipped, suiteId: resolvedSuiteId } as any, progress: 100 },
1710
1764
  })
1711
1765
  );
1712
1766
  } catch (e: any) {
1713
1767
  const msg = e?.message ?? 'Unknown error';
1714
1768
  logger.error({ jobId, err: msg }, 'processGenerateTestsFromReqsJob failed');
1715
- await withTenant(tenantId, (tx) =>
1716
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
1717
- ).catch(() => { });
1769
+ await recordJobFailure(tenantId, jobId, e);
1718
1770
  }
1719
1771
  }
1720
1772
 
@@ -1768,6 +1820,21 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1768
1820
  let passed = 0;
1769
1821
  let failed = 0;
1770
1822
  const runResults: Array<{ testCaseId: string; status: string }> = [];
1823
+
1824
+ // Resume from checkpoint if present
1825
+ const existingJob = await prismaAdmin.platformJob.findUnique({
1826
+ where: { id: jobId },
1827
+ select: { checkpoint: true },
1828
+ }).catch(() => null);
1829
+ const completedCaseIds = new Set<string>((existingJob?.checkpoint as any)?.completedCaseIds ?? []);
1830
+ if (completedCaseIds.size > 0) {
1831
+ testCases = testCases.filter((tc) => !completedCaseIds.has(tc.id));
1832
+ passed = (existingJob?.checkpoint as any)?.passed ?? 0;
1833
+ failed = (existingJob?.checkpoint as any)?.failed ?? 0;
1834
+ }
1835
+
1836
+ const allFinishedCaseIds: string[] = Array.from(completedCaseIds);
1837
+
1771
1838
  for (let i = 0; i < testCases.length; i += TEST_EXEC_CONCURRENCY) {
1772
1839
  const batch = testCases.slice(i, i + TEST_EXEC_CONCURRENCY);
1773
1840
  await Promise.all(batch.map(async (tc) => {
@@ -1837,6 +1904,18 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1837
1904
  }).catch(() => { });
1838
1905
  }
1839
1906
  }));
1907
+
1908
+ for (const tc of batch) allFinishedCaseIds.push(tc.id);
1909
+
1910
+ // Save durable checkpoint after each batch
1911
+ await prismaAdmin.platformJob.update({
1912
+ where: { id: jobId },
1913
+ data: {
1914
+ checkpoint: { completedCaseIds: allFinishedCaseIds, passed, failed } as any,
1915
+ checkpointAt: new Date(),
1916
+ },
1917
+ }).catch(() => {});
1918
+
1840
1919
  await updateJobProgress(
1841
1920
  tenantId, jobId,
1842
1921
  Math.round((Math.min(i + TEST_EXEC_CONCURRENCY, testCases.length) / testCases.length) * 100),
@@ -1874,9 +1953,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1874
1953
  const msg = String(err?.message ?? err).slice(0, 500);
1875
1954
  logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
1876
1955
  await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => { });
1877
- await withTenant(tenantId, (tx) =>
1878
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
1879
- ).catch(() => { });
1956
+ await recordJobFailure(tenantId, jobId, err);
1880
1957
  }
1881
1958
  }
1882
1959
 
@@ -1918,9 +1995,7 @@ async function processGenerateSkillFilesJob(jobId: string, config: any) {
1918
1995
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', progress: 100, finishedAt: new Date() } })
1919
1996
  );
1920
1997
  } catch (err: any) {
1921
- await withTenant(tenantId, (tx) =>
1922
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
1923
- );
1998
+ await recordJobFailure(tenantId, jobId, err);
1924
1999
  }
1925
2000
  }
1926
2001
 
@@ -2236,16 +2311,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
2236
2311
 
2237
2312
  } catch (err: any) {
2238
2313
  logger.error({ err: err?.message, jobId }, '[discover] Job failed');
2239
- await withTenant(tenantId, (tx) =>
2240
- tx.platformJob.update({
2241
- where: { id: jobId },
2242
- data: {
2243
- status: err?.message === 'CRAWL_ABORTED' ? 'CANCELLED' : 'FAILED',
2244
- result: { error: err?.message ?? String(err) } as any,
2245
- finishedAt: new Date(),
2246
- },
2247
- })
2248
- ).catch(console.error);
2314
+ await recordJobFailure(tenantId, jobId, err);
2249
2315
  } finally {
2250
2316
  clearInterval(heartbeatTimer);
2251
2317
  runningControllers.delete(jobId);
@@ -2359,12 +2425,7 @@ async function processMonitorJob(jobId: string, config: any, opts?: ProcessJobOp
2359
2425
  })
2360
2426
  );
2361
2427
  } catch (err: any) {
2362
- await withTenant(tenantId, (tx) =>
2363
- tx.platformJob.update({
2364
- where: { id: jobId },
2365
- data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
2366
- })
2367
- );
2428
+ await recordJobFailure(tenantId, jobId, err);
2368
2429
  } finally {
2369
2430
  runningControllers.delete(jobId);
2370
2431
  }
@@ -2579,9 +2640,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2579
2640
  } catch (err: any) {
2580
2641
  const msg = err?.message ?? String(err);
2581
2642
  logger.error({ jobId, screenId, err: msg }, 'processGenerateScreenTestsJob failed');
2582
- await withTenant(tenantId, (tx: any) =>
2583
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg.slice(0, 500), finishedAt: new Date() } })
2584
- ).catch(() => { });
2643
+ await recordJobFailure(tenantId, jobId, err);
2585
2644
  }
2586
2645
  }
2587
2646
 
@@ -2854,11 +2913,8 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2854
2913
  );
2855
2914
  } catch (err: any) {
2856
2915
  const error = String(err?.message ?? err);
2857
- await withTenant(tenantId, (tx) =>
2858
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
2859
- ).catch(() => { });
2860
2916
  logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
2861
- throw err;
2917
+ await recordJobFailure(tenantId, jobId, err);
2862
2918
  } finally {
2863
2919
  await browser?.close?.().catch(() => { });
2864
2920
  }
@@ -3018,9 +3074,7 @@ async function processMultiRoleCrawlJob(jobId: string, config: any) {
3018
3074
  } catch (err: any) {
3019
3075
  const msg = String(err?.message ?? err);
3020
3076
  logger.error({ jobId, err: msg }, '[multi-role-crawl] failed');
3021
- await withTenant(tenantId, (tx) =>
3022
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg, finishedAt: new Date() } })
3023
- ).catch(() => { });
3077
+ await recordJobFailure(tenantId, jobId, err);
3024
3078
  }
3025
3079
  }
3026
3080
 
@@ -3126,9 +3180,7 @@ async function processRoleCrawlJob(jobId: string, config: any) {
3126
3180
  } catch (err: any) {
3127
3181
  const msg = String(err?.message ?? err);
3128
3182
  logger.error({ jobId, err: msg }, '[role-crawl] outer failure');
3129
- await withTenant(tenantId, (tx) =>
3130
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg, finishedAt: new Date() } })
3131
- ).catch(() => { });
3183
+ await recordJobFailure(tenantId, jobId, err);
3132
3184
  }
3133
3185
  }
3134
3186
 
@@ -3276,9 +3328,7 @@ async function processArtifactIntelJob(jobId: string, config: any) {
3276
3328
  await withTenant(tenantId, (tx) =>
3277
3329
  (tx as any).projectArtifact.update({ where: { id: artifactId }, data: { status: 'ERROR', summary: 'AI analysis failed' } })
3278
3330
  ).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(() => { });
3331
+ await recordJobFailure(tenantId, jobId, err);
3282
3332
  queueImportedArtifactSkillRefresh(tenantId, projectId, 'artifact-intel-failed');
3283
3333
  }
3284
3334
  }
@@ -3789,9 +3839,7 @@ async function processRepoScanJob(jobId: string, config: any) {
3789
3839
  await withTenant(tenantId, (tx) =>
3790
3840
  tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })
3791
3841
  ).catch(() => { });
3792
- await withTenant(tenantId, (tx) =>
3793
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3794
- ).catch(() => { });
3842
+ await recordJobFailure(tenantId, jobId, err);
3795
3843
  }
3796
3844
  }
3797
3845
 
@@ -3912,9 +3960,7 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
3912
3960
  } catch (err: any) {
3913
3961
  const msg = String(err?.message ?? err);
3914
3962
  logger.error({ jobId, err: msg }, '[batch-generate-aligned] failed');
3915
- await withTenant(tenantId, (tx) =>
3916
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3917
- ).catch(() => { });
3963
+ await recordJobFailure(tenantId, jobId, err);
3918
3964
  }
3919
3965
  }
3920
3966
 
@@ -4041,9 +4087,7 @@ async function processCreatePRJob(jobId: string, config: any) {
4041
4087
  } catch (err: any) {
4042
4088
  const msg = String(err?.message ?? err);
4043
4089
  logger.error({ jobId, err: msg }, '[create-pr] failed');
4044
- await withTenant(tenantId, (tx) =>
4045
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
4046
- ).catch(() => { });
4090
+ await recordJobFailure(tenantId, jobId, err);
4047
4091
  }
4048
4092
  }
4049
4093
 
@@ -4145,8 +4189,6 @@ async function processNewPagesPipelineJob(jobId: string, config: any) {
4145
4189
  } catch (err: any) {
4146
4190
  const msg = String(err?.message ?? err);
4147
4191
  logger.error({ jobId, err: msg }, '[new-pages-pipeline] failed');
4148
- await withTenant(tenantId, (tx) =>
4149
- tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
4150
- ).catch(() => { });
4192
+ await recordJobFailure(tenantId, jobId, err);
4151
4193
  }
4152
4194
  }
@@ -20,6 +20,18 @@ export declare enum FailureType {
20
20
  FATAL = "FATAL"
21
21
  }
22
22
  export declare function classifyFailure(error: string | Error | unknown): FailureType;
23
+ /**
24
+ * Centralized failure recorder for all PlatformJobs.
25
+ * - Classifies error into FailureType (TRANSIENT, INFRASTRUCTURE, LOGICAL, FATAL)
26
+ * - Computes exponential backoff per failure classification
27
+ * - If FATAL or retries exhausted: marks status='FAILED', dlq=true, dlqAt=NOW(), dlqReason=error
28
+ * - If retryable: marks status='RETRY_WAIT', nextRetryAt=NOW() + backoff, retryCount=retryCount+1
29
+ * - Persists checkpoint and checkpointAt if checkpointData provided
30
+ * - Updates failureType on the row and in result.failureType
31
+ */
32
+ export declare function recordJobFailure(tenantId: string | null | undefined, jobId: string, err: unknown, checkpointData?: any, options?: {
33
+ isAborted?: boolean;
34
+ }): Promise<void>;
23
35
  export declare function generateTOTP(secret: string): string;
24
36
  export interface PlatformJobConfig {
25
37
  tenantId: string;