@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/dist/crawler.js +9 -0
- package/dist/crawler.js.map +2 -2
- package/dist/failure-analysis-agent.js +2 -1
- package/dist/failure-analysis-agent.js.map +2 -2
- package/dist/job-queue.js +177 -195
- package/dist/job-queue.js.map +2 -2
- package/package.json +1 -1
- package/src/job-queue.test.ts +69 -2
- package/src/job-queue.ts +202 -160
- package/types/job-queue.d.ts +12 -0
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
|
-
|
|
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:
|
|
800
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1652
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1706
|
+
const user = `Generate test cases for these requirements:\n\n${reqBlock}`;
|
|
1666
1707
|
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
-
|
|
1695
|
-
tenantId, projectId, resolvedSuiteId, normalized, { duplicateMode: 'skip' }
|
|
1696
|
-
);
|
|
1745
|
+
for (const r of chunk) finishedReqIds.push(r.reqId);
|
|
1697
1746
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/types/job-queue.d.ts
CHANGED
|
@@ -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;
|