@detiq/crawler 1.1.26 → 1.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crawler.js +2 -3
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +299 -64
- package/dist/job-queue.js.map +3 -3
- package/package.json +1 -1
- package/src/crawler.ts +5 -2
- package/src/failure-analysis-agent.ts +2 -1
- package/src/job-queue.ts +109 -30
- package/types/crawler.d.ts +4 -0
- package/types/job-queue.d.ts +44 -3
package/dist/job-queue.js
CHANGED
|
@@ -1,36 +1,15 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import { withTenant, prismaAdmin } from "@detiq/database";
|
|
3
2
|
import { crawlJobsTotal, crawlJobDurationSeconds } from "@detiq/core";
|
|
4
3
|
import { logger } from "./logger.js";
|
|
4
|
+
import { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall, downloadBufferFromTenantStorage, dispatchToTenantAdmins } from "@detiq/app-brain";
|
|
5
5
|
async function notifyAdmins(tenantId, data) {
|
|
6
6
|
try {
|
|
7
|
-
|
|
8
|
-
tenantId,
|
|
9
|
-
(tx) => tx.membership.findMany({
|
|
10
|
-
where: { tenantId, role: { in: ["OWNER", "ADMIN"] } },
|
|
11
|
-
select: { userId: true }
|
|
12
|
-
})
|
|
13
|
-
);
|
|
14
|
-
await withTenant(
|
|
15
|
-
tenantId,
|
|
16
|
-
(tx) => tx.notification.createMany({
|
|
17
|
-
data: members.map((m) => ({
|
|
18
|
-
id: `notif_${crypto.randomUUID()}`,
|
|
19
|
-
tenantId,
|
|
20
|
-
userId: m.userId,
|
|
21
|
-
type: data.type,
|
|
22
|
-
title: data.title,
|
|
23
|
-
body: data.body,
|
|
24
|
-
metadata: data.metadata
|
|
25
|
-
}))
|
|
26
|
-
})
|
|
27
|
-
);
|
|
7
|
+
await dispatchToTenantAdmins(tenantId, data);
|
|
28
8
|
} catch (e) {
|
|
29
9
|
console.error("[notify] failed:", e);
|
|
30
10
|
}
|
|
31
11
|
}
|
|
32
|
-
import {
|
|
33
|
-
import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from "@detiq/agents";
|
|
12
|
+
import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog, generateArtifactIntelligence } from "@detiq/agents";
|
|
34
13
|
import { crawlProject, domFallbackElements } from "./crawler.js";
|
|
35
14
|
import { httpCrawl } from "./http-engine.js";
|
|
36
15
|
import { hashContent, normalizeText } from "./incremental-diff.js";
|
|
@@ -44,6 +23,38 @@ import { createHmac } from "node:crypto";
|
|
|
44
23
|
import fs from "node:fs/promises";
|
|
45
24
|
import path from "node:path";
|
|
46
25
|
import os from "node:os";
|
|
26
|
+
var FailureType = /* @__PURE__ */ ((FailureType2) => {
|
|
27
|
+
FailureType2["TRANSIENT"] = "TRANSIENT";
|
|
28
|
+
FailureType2["INFRASTRUCTURE"] = "INFRASTRUCTURE";
|
|
29
|
+
FailureType2["LOGICAL"] = "LOGICAL";
|
|
30
|
+
FailureType2["FATAL"] = "FATAL";
|
|
31
|
+
return FailureType2;
|
|
32
|
+
})(FailureType || {});
|
|
33
|
+
const BACKOFF_BY_TYPE = {
|
|
34
|
+
["TRANSIENT" /* TRANSIENT */]: { maxRetries: 5, baseDelayMs: 3e4, multiplier: 2, maxDelayMs: 3e5 },
|
|
35
|
+
["INFRASTRUCTURE" /* INFRASTRUCTURE */]: { maxRetries: 3, baseDelayMs: 6e4, multiplier: 3, maxDelayMs: 6e5 },
|
|
36
|
+
["LOGICAL" /* LOGICAL */]: { maxRetries: 1, baseDelayMs: 12e4, multiplier: 1, maxDelayMs: 12e4 },
|
|
37
|
+
["FATAL" /* FATAL */]: { maxRetries: 0, baseDelayMs: 0, multiplier: 1, maxDelayMs: 0 }
|
|
38
|
+
};
|
|
39
|
+
function classifyFailure(error) {
|
|
40
|
+
const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
41
|
+
if (msg.includes("master_enc_key") || msg.includes("missing secret") || msg.includes("invalid configuration")) {
|
|
42
|
+
return "FATAL" /* FATAL */;
|
|
43
|
+
}
|
|
44
|
+
if (msg.includes("out of memory") || msg.includes("heap out") || msg.includes("connection refused") || msg.includes("econnrefused") || msg.includes("prisma") || msg.includes("database") || msg.includes("worker exited") || msg.includes("sigkill")) {
|
|
45
|
+
return "INFRASTRUCTURE" /* INFRASTRUCTURE */;
|
|
46
|
+
}
|
|
47
|
+
if (msg.includes("validation") || msg.includes("invalid input") || msg.includes("not found") || msg.includes("unauthorized") || msg.includes("bad request") || msg.includes("400") || msg.includes("422") || msg.includes("403")) {
|
|
48
|
+
return "LOGICAL" /* LOGICAL */;
|
|
49
|
+
}
|
|
50
|
+
return "TRANSIENT" /* TRANSIENT */;
|
|
51
|
+
}
|
|
52
|
+
function computeNextRetryAt(failureType, retryCount) {
|
|
53
|
+
const config = BACKOFF_BY_TYPE[failureType];
|
|
54
|
+
if (retryCount >= config.maxRetries) return null;
|
|
55
|
+
const delayMs = Math.min(config.baseDelayMs * Math.pow(config.multiplier, retryCount), config.maxDelayMs);
|
|
56
|
+
return new Date(Date.now() + delayMs);
|
|
57
|
+
}
|
|
47
58
|
function generateTOTP(secret) {
|
|
48
59
|
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
49
60
|
const clean = secret.toUpperCase().replace(/\s/g, "").replace(/=/g, "");
|
|
@@ -83,8 +94,19 @@ function makeJobName(type) {
|
|
|
83
94
|
const runningControllers = /* @__PURE__ */ new Map();
|
|
84
95
|
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
85
96
|
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
97
|
+
const MAX_CONCURRENT_PER_TENANT = Number(process.env.MAX_CONCURRENT_PER_TENANT) || 4;
|
|
86
98
|
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15e3;
|
|
87
99
|
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
100
|
+
let activeExecutions = 0;
|
|
101
|
+
function withSemaphore(fn) {
|
|
102
|
+
activeExecutions++;
|
|
103
|
+
return fn().finally(() => {
|
|
104
|
+
activeExecutions--;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function getActiveExecutionCount() {
|
|
108
|
+
return activeExecutions;
|
|
109
|
+
}
|
|
88
110
|
const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
|
|
89
111
|
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15e3;
|
|
90
112
|
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) || 5;
|
|
@@ -94,6 +116,7 @@ async function claimSpecificJob(jobId) {
|
|
|
94
116
|
UPDATE "PlatformJob"
|
|
95
117
|
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
96
118
|
WHERE id = ${jobId} AND status = 'QUEUED'
|
|
119
|
+
AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
|
|
97
120
|
RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
|
|
98
121
|
`;
|
|
99
122
|
return rows[0] ?? null;
|
|
@@ -104,13 +127,20 @@ async function claimNextQueuedJob() {
|
|
|
104
127
|
UPDATE "PlatformJob"
|
|
105
128
|
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
106
129
|
WHERE id = (
|
|
107
|
-
SELECT id FROM "PlatformJob"
|
|
130
|
+
SELECT id FROM "PlatformJob" p
|
|
108
131
|
WHERE status = 'QUEUED'
|
|
132
|
+
AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
|
|
109
133
|
AND (
|
|
110
134
|
type != 'DISCOVER'
|
|
111
135
|
OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
|
|
112
136
|
)
|
|
113
|
-
|
|
137
|
+
AND (
|
|
138
|
+
SELECT COUNT(*) FROM "PlatformJob" p2
|
|
139
|
+
WHERE p2.status = 'RUNNING'
|
|
140
|
+
AND p2."tenantId" = p."tenantId"
|
|
141
|
+
AND p2.inline = false
|
|
142
|
+
) < ${MAX_CONCURRENT_PER_TENANT}
|
|
143
|
+
ORDER BY p."priority" DESC, p."queuedAt" ASC
|
|
114
144
|
FOR UPDATE SKIP LOCKED
|
|
115
145
|
LIMIT 1
|
|
116
146
|
)
|
|
@@ -192,40 +222,72 @@ async function dispatchClaimedJob(job) {
|
|
|
192
222
|
stopTimer();
|
|
193
223
|
crawlJobsTotal.inc({ type: job.type, status: "failed" });
|
|
194
224
|
logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, "[job-worker] job threw uncaught error");
|
|
225
|
+
const errorMessage = String(err?.message ?? err);
|
|
226
|
+
const failureType = classifyFailure(err);
|
|
227
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { retryCount: true, result: true } }).catch(() => null);
|
|
228
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
229
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
195
230
|
await prismaAdmin.platformJob.update({
|
|
196
231
|
where: { id: job.id },
|
|
197
|
-
data: {
|
|
232
|
+
data: {
|
|
233
|
+
status: nextRetryAt ? "RETRY_WAIT" : "FAILED",
|
|
234
|
+
error: errorMessage,
|
|
235
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
236
|
+
heartbeatAt: null,
|
|
237
|
+
workerId: null,
|
|
238
|
+
nextRetryAt,
|
|
239
|
+
retryCount: { increment: 1 },
|
|
240
|
+
failureType,
|
|
241
|
+
// Store failureType in result JSON for observability (backward compat)
|
|
242
|
+
result: { ...jobRow?.result ?? {}, failureType }
|
|
243
|
+
}
|
|
198
244
|
}).catch(() => {
|
|
199
245
|
});
|
|
246
|
+
if (!nextRetryAt || failureType === "FATAL" /* FATAL */) {
|
|
247
|
+
await prismaAdmin.platformJob.update({
|
|
248
|
+
where: { id: job.id },
|
|
249
|
+
data: { dlq: true, dlqAt: /* @__PURE__ */ new Date(), dlqReason: errorMessage }
|
|
250
|
+
}).catch(() => {
|
|
251
|
+
});
|
|
252
|
+
}
|
|
200
253
|
}
|
|
201
254
|
}
|
|
202
255
|
let workerLoopTimer = null;
|
|
256
|
+
let workerShuttingDown = false;
|
|
203
257
|
async function retryEligibleFailedJobs() {
|
|
204
258
|
const rows = await prismaAdmin.$queryRaw`
|
|
205
259
|
UPDATE "PlatformJob"
|
|
206
|
-
SET status = '
|
|
260
|
+
SET status = 'RETRY_WAIT',
|
|
207
261
|
"retryCount" = "retryCount" + 1,
|
|
208
262
|
"nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
|
|
209
263
|
"workerId" = NULL
|
|
210
264
|
WHERE status = 'FAILED'
|
|
211
265
|
AND dlq = false
|
|
212
|
-
AND "nextRetryAt"
|
|
213
|
-
AND "retryCount" <
|
|
214
|
-
WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
|
|
215
|
-
WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
|
|
216
|
-
WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
|
|
217
|
-
ELSE 1
|
|
218
|
-
END
|
|
266
|
+
AND "nextRetryAt" IS NULL
|
|
267
|
+
AND "retryCount" < 5
|
|
219
268
|
RETURNING id
|
|
220
269
|
`;
|
|
221
270
|
if (rows.length > 0) {
|
|
222
|
-
logger.info({ jobIds: rows.map((r) => r.id) }, "[job-worker]
|
|
271
|
+
logger.info({ jobIds: rows.map((r) => r.id) }, "[job-worker] moved failed job(s) to RETRY_WAIT");
|
|
272
|
+
}
|
|
273
|
+
const readyRows = await prismaAdmin.$queryRaw`
|
|
274
|
+
UPDATE "PlatformJob"
|
|
275
|
+
SET status = 'QUEUED',
|
|
276
|
+
"requeuedAt" = NOW()
|
|
277
|
+
WHERE status = 'RETRY_WAIT'
|
|
278
|
+
AND "nextRetryAt" <= NOW()
|
|
279
|
+
AND dlq = false
|
|
280
|
+
RETURNING id
|
|
281
|
+
`;
|
|
282
|
+
if (readyRows.length > 0) {
|
|
283
|
+
logger.info({ jobIds: readyRows.map((r) => r.id) }, "[job-worker] promoted RETRY_WAIT job(s) to QUEUED");
|
|
223
284
|
}
|
|
224
285
|
}
|
|
225
286
|
async function promoteToDeadLetterQueue() {
|
|
226
287
|
const rows = await prismaAdmin.$queryRaw`
|
|
227
288
|
UPDATE "PlatformJob"
|
|
228
289
|
SET dlq = true,
|
|
290
|
+
"dlqAt" = NOW(),
|
|
229
291
|
"dlqReason" = error
|
|
230
292
|
WHERE status = 'FAILED'
|
|
231
293
|
AND dlq = false
|
|
@@ -246,7 +308,7 @@ function startJobWorkerLoop(opts) {
|
|
|
246
308
|
const pollIntervalMs = opts?.pollIntervalMs ?? WORKER_POLL_INTERVAL_MS;
|
|
247
309
|
const concurrency = opts?.concurrency ?? WORKER_CONCURRENCY;
|
|
248
310
|
logger.info({ workerId: WORKER_ID, pollIntervalMs, concurrency }, "[job-worker] starting");
|
|
249
|
-
const
|
|
311
|
+
const sweepTick = async () => {
|
|
250
312
|
try {
|
|
251
313
|
await retryEligibleFailedJobs();
|
|
252
314
|
await promoteToDeadLetterQueue();
|
|
@@ -254,28 +316,46 @@ function startJobWorkerLoop(opts) {
|
|
|
254
316
|
} catch (err) {
|
|
255
317
|
logger.error({ err: String(err) }, "[job-worker] stale/retry job sweep failed");
|
|
256
318
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
319
|
+
};
|
|
320
|
+
workerLoopTimer = setInterval(sweepTick, pollIntervalMs);
|
|
321
|
+
workerShuttingDown = false;
|
|
322
|
+
const pollLoop = async () => {
|
|
323
|
+
void sweepTick();
|
|
324
|
+
while (!workerShuttingDown) {
|
|
325
|
+
if (activeExecutions >= concurrency) {
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
let paused = false;
|
|
330
|
+
try {
|
|
331
|
+
const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: "queue.paused" } });
|
|
332
|
+
paused = pauseRow?.value === "true";
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
if (paused) {
|
|
260
336
|
logger.info("[job-worker] queue is paused \u2014 skipping claim");
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const job = await claimNextQueuedJob();
|
|
264
|
-
if (!job) break;
|
|
265
|
-
void dispatchClaimedJob(job);
|
|
266
|
-
}
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
338
|
+
continue;
|
|
267
339
|
}
|
|
268
|
-
|
|
269
|
-
|
|
340
|
+
let job = null;
|
|
341
|
+
try {
|
|
342
|
+
job = await claimNextQueuedJob();
|
|
343
|
+
} catch (err) {
|
|
344
|
+
logger.error({ err: String(err) }, "[job-worker] claim failed");
|
|
345
|
+
}
|
|
346
|
+
if (!job) {
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
withSemaphore(() => dispatchClaimedJob(job)).catch((err) => {
|
|
351
|
+
logger.error({ err, jobId: job.id }, "[worker] unhandled job error");
|
|
352
|
+
});
|
|
270
353
|
}
|
|
271
354
|
};
|
|
272
|
-
|
|
273
|
-
requeueOrphanedJobs().catch(
|
|
274
|
-
(err) => logger.error({ err: String(err) }, "[job-worker] startup orphan sweep failed")
|
|
275
|
-
);
|
|
276
|
-
void tick();
|
|
355
|
+
void pollLoop();
|
|
277
356
|
}
|
|
278
357
|
function stopJobWorkerLoop() {
|
|
358
|
+
workerShuttingDown = true;
|
|
279
359
|
if (workerLoopTimer) clearInterval(workerLoopTimer);
|
|
280
360
|
workerLoopTimer = null;
|
|
281
361
|
}
|
|
@@ -284,28 +364,50 @@ async function createTrackedJob(type, config) {
|
|
|
284
364
|
const job = await withTenant(
|
|
285
365
|
config.tenantId,
|
|
286
366
|
(tx) => tx.platformJob.create({
|
|
287
|
-
data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), config }
|
|
367
|
+
data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), inline: true, config }
|
|
288
368
|
})
|
|
289
369
|
);
|
|
290
370
|
return job.id;
|
|
291
371
|
}
|
|
292
372
|
async function finishTrackedJob(tenantId, jobId, error) {
|
|
293
|
-
|
|
373
|
+
if (!error) {
|
|
374
|
+
await withTenant(
|
|
375
|
+
tenantId,
|
|
376
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100 } })
|
|
377
|
+
).catch(() => {
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const failureType = classifyFailure(error);
|
|
382
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: jobId }, select: { retryCount: true, result: true } }).catch(() => null);
|
|
383
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
384
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
294
385
|
await withTenant(
|
|
295
386
|
tenantId,
|
|
296
387
|
(tx) => tx.platformJob.update({
|
|
297
388
|
where: { id: jobId },
|
|
298
389
|
data: {
|
|
299
|
-
status:
|
|
390
|
+
status: nextRetryAt ? "RETRY_WAIT" : "FAILED",
|
|
300
391
|
finishedAt: /* @__PURE__ */ new Date(),
|
|
301
|
-
|
|
392
|
+
error,
|
|
393
|
+
nextRetryAt,
|
|
394
|
+
retryCount: { increment: 1 },
|
|
395
|
+
failureType,
|
|
396
|
+
result: { ...jobRow?.result ?? {}, failureType }
|
|
302
397
|
}
|
|
303
398
|
})
|
|
304
399
|
).catch(() => {
|
|
305
400
|
});
|
|
401
|
+
if (!nextRetryAt || failureType === "FATAL" /* FATAL */) {
|
|
402
|
+
await prismaAdmin.platformJob.update({
|
|
403
|
+
where: { id: jobId },
|
|
404
|
+
data: { dlq: true, dlqAt: /* @__PURE__ */ new Date(), dlqReason: error }
|
|
405
|
+
}).catch(() => {
|
|
406
|
+
});
|
|
407
|
+
}
|
|
306
408
|
}
|
|
307
409
|
async function enqueueJob(type, config) {
|
|
308
|
-
const { tenantId, projectId } = config;
|
|
410
|
+
const { tenantId, projectId, submittedBy } = config;
|
|
309
411
|
const name = makeJobName(type);
|
|
310
412
|
const job = await withTenant(
|
|
311
413
|
tenantId,
|
|
@@ -316,7 +418,8 @@ async function enqueueJob(type, config) {
|
|
|
316
418
|
type,
|
|
317
419
|
name,
|
|
318
420
|
status: "QUEUED",
|
|
319
|
-
config
|
|
421
|
+
config,
|
|
422
|
+
...submittedBy ? { submittedBy } : {}
|
|
320
423
|
}
|
|
321
424
|
})
|
|
322
425
|
);
|
|
@@ -364,7 +467,7 @@ async function cancelJob(tenantId, jobId) {
|
|
|
364
467
|
tenantId,
|
|
365
468
|
(tx) => tx.platformJob.update({
|
|
366
469
|
where: { id: jobId },
|
|
367
|
-
data: { status: "
|
|
470
|
+
data: { status: "CANCEL_REQUESTED" }
|
|
368
471
|
})
|
|
369
472
|
);
|
|
370
473
|
}
|
|
@@ -403,14 +506,15 @@ async function updateJobProgress(tenantId, jobId, progress, step) {
|
|
|
403
506
|
where: { id: jobId },
|
|
404
507
|
select: { status: true }
|
|
405
508
|
});
|
|
406
|
-
if (current?.status === "CANCELLED") {
|
|
509
|
+
if (current?.status === "CANCELLED" || current?.status === "CANCEL_REQUESTED") {
|
|
407
510
|
runningControllers.get(jobId)?.abort();
|
|
408
511
|
runningControllers.delete(jobId);
|
|
409
512
|
return;
|
|
410
513
|
}
|
|
411
514
|
await tx.platformJob.update({
|
|
412
515
|
where: { id: jobId },
|
|
413
|
-
|
|
516
|
+
// GAP-09: write currentStep as a proper column in addition to result.step (backward compat)
|
|
517
|
+
data: { progress, currentStep: step, result: { step }, heartbeatAt: /* @__PURE__ */ new Date() }
|
|
414
518
|
});
|
|
415
519
|
});
|
|
416
520
|
} catch {
|
|
@@ -423,7 +527,7 @@ async function heartbeatJob(tenantId, jobId) {
|
|
|
423
527
|
where: { id: jobId },
|
|
424
528
|
select: { status: true }
|
|
425
529
|
});
|
|
426
|
-
if (current?.status === "CANCELLED") {
|
|
530
|
+
if (current?.status === "CANCELLED" || current?.status === "CANCEL_REQUESTED") {
|
|
427
531
|
runningControllers.get(jobId)?.abort();
|
|
428
532
|
runningControllers.delete(jobId);
|
|
429
533
|
return;
|
|
@@ -533,6 +637,19 @@ async function processJob(jobId, config, opts) {
|
|
|
533
637
|
storageConfig: storageConfigForCrawl,
|
|
534
638
|
onCheckpoint: async (visitedUrls, pendingQueue) => {
|
|
535
639
|
jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
|
|
640
|
+
const CHECKPOINT_INTERVAL = parseInt(process.env.CRAWL_CHECKPOINT_INTERVAL ?? "10");
|
|
641
|
+
if (visitedUrls.length % CHECKPOINT_INTERVAL === 0) {
|
|
642
|
+
try {
|
|
643
|
+
await prismaAdmin.platformJob.update({
|
|
644
|
+
where: { id: jobId },
|
|
645
|
+
data: {
|
|
646
|
+
checkpoint: { visitedUrls, pendingQueue },
|
|
647
|
+
checkpointAt: /* @__PURE__ */ new Date()
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
} catch {
|
|
651
|
+
}
|
|
652
|
+
}
|
|
536
653
|
}
|
|
537
654
|
}, onProgress);
|
|
538
655
|
jobCheckpoints.delete(jobId);
|
|
@@ -1170,7 +1287,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1170
1287
|
${reqBlock}`;
|
|
1171
1288
|
let parsed = { cases: [] };
|
|
1172
1289
|
try {
|
|
1173
|
-
const reqSeed = deterministicSeed(
|
|
1290
|
+
const reqSeed = deterministicSeed(`req-cases:${reqs.map((r) => r.reqId).sort().join("|").slice(0, 300)}`);
|
|
1174
1291
|
const { text, usage } = await askTracked(system, user, llmCfg, void 0, void 0, { temperature: 0, seed: reqSeed });
|
|
1175
1292
|
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: "generate_tests_from_reqs", label: "test_case_generation", usage }).catch(() => {
|
|
1176
1293
|
});
|
|
@@ -1436,6 +1553,18 @@ async function processDataRetentionPurge() {
|
|
|
1436
1553
|
}
|
|
1437
1554
|
});
|
|
1438
1555
|
logger.info({ deleted: sessionResult.count }, "[data-retention] AuthSession purged");
|
|
1556
|
+
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
|
|
1557
|
+
const deletedJobs = await prismaAdmin.platformJob.deleteMany({
|
|
1558
|
+
where: {
|
|
1559
|
+
status: { in: ["DONE", "FAILED"] },
|
|
1560
|
+
dlq: false,
|
|
1561
|
+
inline: false,
|
|
1562
|
+
finishedAt: { lt: ninetyDaysAgo }
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
if (deletedJobs.count > 0) {
|
|
1566
|
+
logger.info({ count: deletedJobs.count }, "[data-retention] deleted old completed jobs");
|
|
1567
|
+
}
|
|
1439
1568
|
}
|
|
1440
1569
|
async function processDiscoverJob(jobId, config, opts) {
|
|
1441
1570
|
const { tenantId, projectId } = config;
|
|
@@ -2448,7 +2577,110 @@ async function processGenerateScriptJob(jobId, config) {
|
|
|
2448
2577
|
await finishTrackedJob(tenantId, jobId, e?.message ?? "generate-script failed");
|
|
2449
2578
|
}
|
|
2450
2579
|
}
|
|
2580
|
+
function queueImportedArtifactSkillRefresh(tenantId, projectId, reason) {
|
|
2581
|
+
setImmediate(() => {
|
|
2582
|
+
import("@detiq/agents").then((agentsModule) => {
|
|
2583
|
+
const agents = agentsModule;
|
|
2584
|
+
return Promise.allSettled([
|
|
2585
|
+
agents.refreshArtifactKnowledgeSkillFile(tenantId, projectId),
|
|
2586
|
+
agents.refreshRequirementsCoverageSkillFile(tenantId, projectId)
|
|
2587
|
+
]);
|
|
2588
|
+
}).then((results) => {
|
|
2589
|
+
results?.forEach((result) => {
|
|
2590
|
+
if (result.status === "rejected") {
|
|
2591
|
+
console.warn(`[artifacts] skill refresh failed after ${reason}:`, result.reason?.message ?? result.reason);
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
}).catch((e) => console.warn(`[artifacts] skill refresh failed after ${reason}:`, e?.message ?? e));
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
const CONTENT_DETECTED_DOC_TYPES = /* @__PURE__ */ new Set(["FUNCTIONIZE_TC", "SELENIUM_SCRIPT", "ENV_CONFIG"]);
|
|
2598
|
+
const AI_DOC_TYPES = /* @__PURE__ */ new Set([
|
|
2599
|
+
"REQUIREMENTS",
|
|
2600
|
+
"USER_STORY",
|
|
2601
|
+
"TEST_CASES",
|
|
2602
|
+
"TEST_DATA",
|
|
2603
|
+
"AUTOMATION_SCRIPT",
|
|
2604
|
+
"API_SPEC",
|
|
2605
|
+
"ARCHITECTURE",
|
|
2606
|
+
"REPORT",
|
|
2607
|
+
"OTHER",
|
|
2608
|
+
"SPREADSHEET",
|
|
2609
|
+
"DATASHEET"
|
|
2610
|
+
]);
|
|
2611
|
+
const AI_DOC_TYPE_ALIASES = {
|
|
2612
|
+
REQUIREMENT: "REQUIREMENTS",
|
|
2613
|
+
SPEC: "REQUIREMENTS",
|
|
2614
|
+
SPECIFICATION: "REQUIREMENTS",
|
|
2615
|
+
USER_STORIES: "USER_STORY",
|
|
2616
|
+
STORY: "USER_STORY",
|
|
2617
|
+
TEST_CASE: "TEST_CASES",
|
|
2618
|
+
TESTCASES: "TEST_CASES",
|
|
2619
|
+
TESTDATA: "TEST_DATA",
|
|
2620
|
+
SCRIPT: "AUTOMATION_SCRIPT",
|
|
2621
|
+
AUTOMATION: "AUTOMATION_SCRIPT"
|
|
2622
|
+
};
|
|
2623
|
+
function normalizeAiDocType(raw) {
|
|
2624
|
+
const key = String(raw ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
|
|
2625
|
+
const mapped = AI_DOC_TYPE_ALIASES[key] ?? key;
|
|
2626
|
+
return AI_DOC_TYPES.has(mapped) ? mapped : "OTHER";
|
|
2627
|
+
}
|
|
2628
|
+
async function processArtifactIntelJob(jobId, config) {
|
|
2629
|
+
const { tenantId, projectId, artifactId, artifactName, isReprocess } = config;
|
|
2630
|
+
try {
|
|
2631
|
+
await updateJobProgress(tenantId, jobId, 10, "Downloading artifact content");
|
|
2632
|
+
const artifact = await withTenant(
|
|
2633
|
+
tenantId,
|
|
2634
|
+
(tx) => tx.projectArtifact.findUnique({ where: { id: artifactId }, select: { storageKey: true, docType: true } })
|
|
2635
|
+
);
|
|
2636
|
+
if (!artifact || !artifact.storageKey) {
|
|
2637
|
+
await updateJobProgress(tenantId, jobId, 100, "Artifact deleted or missing content, skipping.");
|
|
2638
|
+
await withTenant(
|
|
2639
|
+
tenantId,
|
|
2640
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } })
|
|
2641
|
+
);
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const storageCfg = await getTenantStorageConfig(tenantId);
|
|
2645
|
+
if (!storageCfg) {
|
|
2646
|
+
throw new Error("Tenant storage configuration missing.");
|
|
2647
|
+
}
|
|
2648
|
+
const contentBuffer = await downloadBufferFromTenantStorage(storageCfg, artifact.storageKey);
|
|
2649
|
+
const content = contentBuffer.toString("utf8");
|
|
2650
|
+
await updateJobProgress(tenantId, jobId, 40, "Generating intelligence");
|
|
2651
|
+
const intel = await generateArtifactIntelligence(tenantId, projectId, artifactId, content, artifactName, jobId);
|
|
2652
|
+
const finalDocType = artifact.docType && CONTENT_DETECTED_DOC_TYPES.has(artifact.docType) ? artifact.docType : normalizeAiDocType(intel.docType);
|
|
2653
|
+
if (finalDocType !== intel.docType) {
|
|
2654
|
+
await withTenant(
|
|
2655
|
+
tenantId,
|
|
2656
|
+
(tx) => tx.projectArtifact.update({ where: { id: artifactId }, data: { docType: finalDocType } })
|
|
2657
|
+
).catch((err) => console.warn(`[artifact-intel] docType fix-up failed for "${artifactName}":`, err?.message));
|
|
2658
|
+
intel.docType = finalDocType;
|
|
2659
|
+
}
|
|
2660
|
+
await updateJobProgress(tenantId, jobId, 90, "Saving results");
|
|
2661
|
+
await withTenant(
|
|
2662
|
+
tenantId,
|
|
2663
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { result: { artifactId, artifactName, summary: intel.summary, keywords: intel.keywords, docType: intel.docType }, status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100 } })
|
|
2664
|
+
);
|
|
2665
|
+
queueImportedArtifactSkillRefresh(tenantId, projectId, isReprocess ? "artifact-reprocess" : "artifact-intel");
|
|
2666
|
+
} catch (err) {
|
|
2667
|
+
const msg = String(err?.message ?? err);
|
|
2668
|
+
logger.error({ jobId, err: msg }, "[artifact-intel] failed");
|
|
2669
|
+
await withTenant(
|
|
2670
|
+
tenantId,
|
|
2671
|
+
(tx) => tx.projectArtifact.update({ where: { id: artifactId }, data: { status: "ERROR", summary: "AI analysis failed" } })
|
|
2672
|
+
).catch(() => {
|
|
2673
|
+
});
|
|
2674
|
+
await withTenant(
|
|
2675
|
+
tenantId,
|
|
2676
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
2677
|
+
).catch(() => {
|
|
2678
|
+
});
|
|
2679
|
+
queueImportedArtifactSkillRefresh(tenantId, projectId, "artifact-intel-failed");
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2451
2682
|
jobDispatchTable = {
|
|
2683
|
+
ARTIFACT_INTEL: processArtifactIntelJob,
|
|
2452
2684
|
CRAWL: processJob,
|
|
2453
2685
|
CAPTURE_STEPS: processCaptureStepsJob,
|
|
2454
2686
|
DISCOVER: processDiscoverJob,
|
|
@@ -3280,15 +3512,18 @@ async function processNewPagesPipelineJob(jobId, config) {
|
|
|
3280
3512
|
}
|
|
3281
3513
|
}
|
|
3282
3514
|
export {
|
|
3515
|
+
FailureType,
|
|
3283
3516
|
aggregateJobTokens,
|
|
3284
3517
|
cancelJob,
|
|
3285
3518
|
claimNextQueuedJob,
|
|
3286
3519
|
claimSpecificJob,
|
|
3520
|
+
classifyFailure,
|
|
3287
3521
|
createTrackedJob,
|
|
3288
3522
|
dispatchClaimedJob,
|
|
3289
3523
|
enqueueJob,
|
|
3290
3524
|
finishTrackedJob,
|
|
3291
3525
|
generateTOTP,
|
|
3526
|
+
getActiveExecutionCount,
|
|
3292
3527
|
getJob,
|
|
3293
3528
|
listJobs,
|
|
3294
3529
|
processDataRetentionPurge,
|