@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
|
@@ -23,7 +23,8 @@ Error: ${ctx.error}${consoleSection}${networkSection}`;
|
|
|
23
23
|
if (ctx.screenshotPath) {
|
|
24
24
|
try {
|
|
25
25
|
const img = readFileSync(ctx.screenshotPath);
|
|
26
|
-
const
|
|
26
|
+
const imgSeed = deterministicSeed(`failure-img:${ctx.tenantId ?? ""}:${(ctx.stepDescription ?? "").slice(0, 100)}:${ctx.error.slice(0, 200)}`);
|
|
27
|
+
const result2 = await askTrackedWithImages("", userText, [{ base64: img.toString("base64"), mediaType: "image/png" }], llmCfg, { temperature: 0, seed: imgSeed });
|
|
27
28
|
return result2.text || null;
|
|
28
29
|
} catch {
|
|
29
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/failure-analysis-agent.ts"],
|
|
4
|
-
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { askTrackedWithImages, askTracked, deterministicSeed } from '@detiq/agents';\nimport type { LLMConfig } from '@detiq/agents';\nimport { AppBrain } from '@detiq/app-brain';\n\nexport interface FailureContext {\n tenantId?: string;\n error: string;\n stepDescription?: string;\n consoleLogs?: Array<{ type: string; text: string }>;\n networkErrors?: Array<{ url: string; status: number; method: string }>;\n screenshotPath?: string;\n}\n\nexport async function analyzeFailure(ctx: FailureContext): Promise<string | null> {\n try {\n let llmCfg: LLMConfig | undefined;\n if (ctx.tenantId) {\n llmCfg = await AppBrain.getTaskLLMConfig(ctx.tenantId, 'testRunner').catch(() => undefined) as LLMConfig | undefined ?? undefined;\n }\n if (!llmCfg) return null;\n\n const consoleSection = ctx.consoleLogs?.length\n ? `\\n\\nBrowser console (last ${ctx.consoleLogs.length} entries):\\n${ctx.consoleLogs.map(l => `[${l.type}] ${l.text}`).join('\\n')}`\n : '';\n\n const networkSection = ctx.networkErrors?.length\n ? `\\n\\nFailed network requests:\\n${ctx.networkErrors.map(n => `${n.method} ${n.url} \u2192 ${n.status}`).join('\\n')}`\n : '';\n\n const userText = `A Playwright automated test failed. Identify the root cause in 2-3 sentences. Be specific \u2014 name the failing element, API endpoint, or error type. Do not suggest fixes.\n\nTest step: ${ctx.stepDescription ?? 'unknown'}\nError: ${ctx.error}${consoleSection}${networkSection}`;\n\n if (ctx.screenshotPath) {\n try {\n const img = readFileSync(ctx.screenshotPath);\n const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg);\n return result.text || null;\n } catch { /* fall through to text-only */ }\n }\n\n const failureSeed = deterministicSeed(`${ctx.tenantId ?? ''}:failure:${(ctx.stepDescription ?? '').slice(0, 100)}:${ctx.error.slice(0, 200)}`);\n const result = await askTracked('', userText, llmCfg, 256, undefined, { temperature: 0, seed: failureSeed });\n return result.text || null;\n } catch {\n return null;\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB,YAAY,yBAAyB;AAEpE,SAAS,gBAAgB;AAWzB,eAAsB,eAAe,KAA6C;AAChF,MAAI;AACF,QAAI;AACJ,QAAI,IAAI,UAAU;AAChB,eAAS,MAAM,SAAS,iBAAiB,IAAI,UAAU,YAAY,EAAE,MAAM,MAAM,MAAS,KAA8B;AAAA,IAC1H;AACA,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,iBAAiB,IAAI,aAAa,SACpC;AAAA;AAAA,wBAA6B,IAAI,YAAY,MAAM;AAAA,EAAe,IAAI,YAAY,IAAI,OAAK,IAAI,EAAE,IAAI,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,IAAI,CAAC,KAC9H;AAEJ,UAAM,iBAAiB,IAAI,eAAe,SACtC;AAAA;AAAA;AAAA,EAAiC,IAAI,cAAc,IAAI,OAAK,GAAG,EAAE,MAAM,IAAI,EAAE,GAAG,WAAM,EAAE,MAAM,EAAE,EAAE,KAAK,IAAI,CAAC,KAC5G;AAEJ,UAAM,WAAW;AAAA;AAAA,aAER,IAAI,mBAAmB,SAAS;AAAA,SACpC,IAAI,KAAK,GAAG,cAAc,GAAG,cAAc;AAEhD,QAAI,IAAI,gBAAgB;AACtB,UAAI;AACF,cAAM,MAAM,aAAa,IAAI,cAAc;AAC3C,cAAMA,UAAS,MAAM,qBAAqB,IAAI,UAAU,CAAC,EAAE,QAAQ,IAAI,SAAS,QAAQ,GAAG,WAAW,YAAY,CAAC,GAAG,MAAM;
|
|
4
|
+
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { askTrackedWithImages, askTracked, deterministicSeed } from '@detiq/agents';\nimport type { LLMConfig } from '@detiq/agents';\nimport { AppBrain } from '@detiq/app-brain';\n\nexport interface FailureContext {\n tenantId?: string;\n error: string;\n stepDescription?: string;\n consoleLogs?: Array<{ type: string; text: string }>;\n networkErrors?: Array<{ url: string; status: number; method: string }>;\n screenshotPath?: string;\n}\n\nexport async function analyzeFailure(ctx: FailureContext): Promise<string | null> {\n try {\n let llmCfg: LLMConfig | undefined;\n if (ctx.tenantId) {\n llmCfg = await AppBrain.getTaskLLMConfig(ctx.tenantId, 'testRunner').catch(() => undefined) as LLMConfig | undefined ?? undefined;\n }\n if (!llmCfg) return null;\n\n const consoleSection = ctx.consoleLogs?.length\n ? `\\n\\nBrowser console (last ${ctx.consoleLogs.length} entries):\\n${ctx.consoleLogs.map(l => `[${l.type}] ${l.text}`).join('\\n')}`\n : '';\n\n const networkSection = ctx.networkErrors?.length\n ? `\\n\\nFailed network requests:\\n${ctx.networkErrors.map(n => `${n.method} ${n.url} \u2192 ${n.status}`).join('\\n')}`\n : '';\n\n const userText = `A Playwright automated test failed. Identify the root cause in 2-3 sentences. Be specific \u2014 name the failing element, API endpoint, or error type. Do not suggest fixes.\n\nTest step: ${ctx.stepDescription ?? 'unknown'}\nError: ${ctx.error}${consoleSection}${networkSection}`;\n\n if (ctx.screenshotPath) {\n try {\n const img = readFileSync(ctx.screenshotPath);\n const imgSeed = deterministicSeed(`failure-img:${ctx.tenantId ?? ''}:${(ctx.stepDescription ?? '').slice(0, 100)}:${ctx.error.slice(0, 200)}`);\n const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg, { temperature: 0, seed: imgSeed });\n return result.text || null;\n } catch { /* fall through to text-only */ }\n }\n\n const failureSeed = deterministicSeed(`${ctx.tenantId ?? ''}:failure:${(ctx.stepDescription ?? '').slice(0, 100)}:${ctx.error.slice(0, 200)}`);\n const result = await askTracked('', userText, llmCfg, 256, undefined, { temperature: 0, seed: failureSeed });\n return result.text || null;\n } catch {\n return null;\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB,YAAY,yBAAyB;AAEpE,SAAS,gBAAgB;AAWzB,eAAsB,eAAe,KAA6C;AAChF,MAAI;AACF,QAAI;AACJ,QAAI,IAAI,UAAU;AAChB,eAAS,MAAM,SAAS,iBAAiB,IAAI,UAAU,YAAY,EAAE,MAAM,MAAM,MAAS,KAA8B;AAAA,IAC1H;AACA,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,iBAAiB,IAAI,aAAa,SACpC;AAAA;AAAA,wBAA6B,IAAI,YAAY,MAAM;AAAA,EAAe,IAAI,YAAY,IAAI,OAAK,IAAI,EAAE,IAAI,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,IAAI,CAAC,KAC9H;AAEJ,UAAM,iBAAiB,IAAI,eAAe,SACtC;AAAA;AAAA;AAAA,EAAiC,IAAI,cAAc,IAAI,OAAK,GAAG,EAAE,MAAM,IAAI,EAAE,GAAG,WAAM,EAAE,MAAM,EAAE,EAAE,KAAK,IAAI,CAAC,KAC5G;AAEJ,UAAM,WAAW;AAAA;AAAA,aAER,IAAI,mBAAmB,SAAS;AAAA,SACpC,IAAI,KAAK,GAAG,cAAc,GAAG,cAAc;AAEhD,QAAI,IAAI,gBAAgB;AACtB,UAAI;AACF,cAAM,MAAM,aAAa,IAAI,cAAc;AAC3C,cAAM,UAAU,kBAAkB,eAAe,IAAI,YAAY,EAAE,KAAK,IAAI,mBAAmB,IAAI,MAAM,GAAG,GAAG,CAAC,IAAI,IAAI,MAAM,MAAM,GAAG,GAAG,CAAC,EAAE;AAC7I,cAAMA,UAAS,MAAM,qBAAqB,IAAI,UAAU,CAAC,EAAE,QAAQ,IAAI,SAAS,QAAQ,GAAG,WAAW,YAAY,CAAC,GAAG,QAAQ,EAAE,aAAa,GAAG,MAAM,QAAQ,CAAC;AAC/J,eAAOA,QAAO,QAAQ;AAAA,MACxB,QAAQ;AAAA,MAAkC;AAAA,IAC5C;AAEA,UAAM,cAAc,kBAAkB,GAAG,IAAI,YAAY,EAAE,aAAa,IAAI,mBAAmB,IAAI,MAAM,GAAG,GAAG,CAAC,IAAI,IAAI,MAAM,MAAM,GAAG,GAAG,CAAC,EAAE;AAC7I,UAAM,SAAS,MAAM,WAAW,IAAI,UAAU,QAAQ,KAAK,QAAW,EAAE,aAAa,GAAG,MAAM,YAAY,CAAC;AAC3G,WAAO,OAAO,QAAQ;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
6
|
"names": ["result"]
|
|
7
7
|
}
|
package/dist/job-queue.js
CHANGED
|
@@ -55,6 +55,55 @@ function computeNextRetryAt(failureType, retryCount) {
|
|
|
55
55
|
const delayMs = Math.min(config.baseDelayMs * Math.pow(config.multiplier, retryCount), config.maxDelayMs);
|
|
56
56
|
return new Date(Date.now() + delayMs);
|
|
57
57
|
}
|
|
58
|
+
async function recordJobFailure(tenantId, jobId, err, checkpointData, options) {
|
|
59
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
60
|
+
const aborted = options?.isAborted ?? (errorMessage.includes("CRAWL_ABORTED") || errorMessage.includes("Stopped by user"));
|
|
61
|
+
if (aborted) {
|
|
62
|
+
await prismaAdmin.platformJob.update({
|
|
63
|
+
where: { id: jobId },
|
|
64
|
+
data: {
|
|
65
|
+
status: "CANCELLED",
|
|
66
|
+
error: "Stopped by user",
|
|
67
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
68
|
+
heartbeatAt: null,
|
|
69
|
+
workerId: null,
|
|
70
|
+
...checkpointData ? { checkpoint: checkpointData, checkpointAt: /* @__PURE__ */ new Date() } : {}
|
|
71
|
+
}
|
|
72
|
+
}).catch(() => {
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const failureType = classifyFailure(err);
|
|
77
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({
|
|
78
|
+
where: { id: jobId },
|
|
79
|
+
select: { retryCount: true, result: true }
|
|
80
|
+
}).catch(() => null);
|
|
81
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
82
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
83
|
+
const isFatal = !nextRetryAt || failureType === "FATAL" /* FATAL */;
|
|
84
|
+
await prismaAdmin.platformJob.update({
|
|
85
|
+
where: { id: jobId },
|
|
86
|
+
data: {
|
|
87
|
+
status: isFatal ? "FAILED" : "RETRY_WAIT",
|
|
88
|
+
error: errorMessage.slice(0, 2e3),
|
|
89
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
90
|
+
heartbeatAt: null,
|
|
91
|
+
workerId: null,
|
|
92
|
+
nextRetryAt,
|
|
93
|
+
retryCount: { increment: 1 },
|
|
94
|
+
failureType,
|
|
95
|
+
dlq: isFatal,
|
|
96
|
+
dlqAt: isFatal ? /* @__PURE__ */ new Date() : null,
|
|
97
|
+
dlqReason: isFatal ? errorMessage.slice(0, 1e3) : null,
|
|
98
|
+
...checkpointData ? { checkpoint: checkpointData, checkpointAt: /* @__PURE__ */ new Date() } : {},
|
|
99
|
+
result: {
|
|
100
|
+
...jobRow?.result ?? {},
|
|
101
|
+
failureType,
|
|
102
|
+
...checkpointData ? { checkpoint: checkpointData } : {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}).catch((e) => logger.error({ err: e, jobId }, "[job-queue] failed to record job failure"));
|
|
106
|
+
}
|
|
58
107
|
function generateTOTP(secret) {
|
|
59
108
|
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
60
109
|
const clean = secret.toUpperCase().replace(/\s/g, "").replace(/=/g, "");
|
|
@@ -156,7 +205,7 @@ async function requeueStaleRunningJobs() {
|
|
|
156
205
|
error = 'API process was restarted while this job was running — please retry from the UI.'
|
|
157
206
|
WHERE status = 'RUNNING'
|
|
158
207
|
AND type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
|
|
159
|
-
AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
208
|
+
AND COALESCE("heartbeatAt", "startedAt") < (NOW() AT TIME ZONE 'UTC') - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
160
209
|
RETURNING id
|
|
161
210
|
`;
|
|
162
211
|
if (failedRows.length > 0) {
|
|
@@ -174,7 +223,7 @@ async function requeueStaleRunningJobs() {
|
|
|
174
223
|
)
|
|
175
224
|
WHERE status = 'RUNNING'
|
|
176
225
|
AND type NOT IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
|
|
177
|
-
AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
226
|
+
AND COALESCE("heartbeatAt", "startedAt") < (NOW() AT TIME ZONE 'UTC') - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
178
227
|
RETURNING id
|
|
179
228
|
`;
|
|
180
229
|
if (rows.length > 0) {
|
|
@@ -217,39 +266,12 @@ async function dispatchClaimedJob(job) {
|
|
|
217
266
|
await dispatcher(job.id, job.config);
|
|
218
267
|
stopTimer();
|
|
219
268
|
const finished = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { status: true } }).catch(() => null);
|
|
220
|
-
crawlJobsTotal.inc({ type: job.type, status: finished?.status === "FAILED" ? "failed" : "success" });
|
|
269
|
+
crawlJobsTotal.inc({ type: job.type, status: finished?.status === "FAILED" || finished?.status === "RETRY_WAIT" ? "failed" : "success" });
|
|
221
270
|
} catch (err) {
|
|
222
271
|
stopTimer();
|
|
223
272
|
crawlJobsTotal.inc({ type: job.type, status: "failed" });
|
|
224
273
|
logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, "[job-worker] job threw uncaught error");
|
|
225
|
-
|
|
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);
|
|
230
|
-
await prismaAdmin.platformJob.update({
|
|
231
|
-
where: { id: job.id },
|
|
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
|
-
}
|
|
244
|
-
}).catch(() => {
|
|
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
|
-
}
|
|
274
|
+
await recordJobFailure(job.tenantId, job.id, err);
|
|
253
275
|
}
|
|
254
276
|
}
|
|
255
277
|
let workerLoopTimer = null;
|
|
@@ -275,7 +297,7 @@ async function retryEligibleFailedJobs() {
|
|
|
275
297
|
SET status = 'QUEUED',
|
|
276
298
|
"requeuedAt" = NOW()
|
|
277
299
|
WHERE status = 'RETRY_WAIT'
|
|
278
|
-
AND "nextRetryAt" <= NOW()
|
|
300
|
+
AND "nextRetryAt" <= (NOW() AT TIME ZONE 'UTC')
|
|
279
301
|
AND dlq = false
|
|
280
302
|
RETURNING id
|
|
281
303
|
`;
|
|
@@ -486,7 +508,7 @@ async function resumeJob(tenantId, jobId) {
|
|
|
486
508
|
(tx) => tx.platformJob.findUnique({ where: { id: jobId } })
|
|
487
509
|
);
|
|
488
510
|
if (!job) throw new Error("Job not found");
|
|
489
|
-
const checkpoint = job.result?.checkpoint;
|
|
511
|
+
const checkpoint = job.checkpoint ?? job.result?.checkpoint;
|
|
490
512
|
const visitedUrls = checkpoint?.visitedUrls ?? [];
|
|
491
513
|
const pendingQueue = checkpoint?.pendingQueue ?? [];
|
|
492
514
|
if (pendingQueue.length === 0 && visitedUrls.length === 0) {
|
|
@@ -495,8 +517,8 @@ async function resumeJob(tenantId, jobId) {
|
|
|
495
517
|
return enqueueJob(job.type, {
|
|
496
518
|
...job.config,
|
|
497
519
|
tenantId,
|
|
498
|
-
skipUrls:
|
|
499
|
-
|
|
520
|
+
skipUrls: [...job.config?.skipUrls ?? [], ...visitedUrls],
|
|
521
|
+
startUrls: pendingQueue.length > 0 ? pendingQueue : job.config?.startUrls
|
|
500
522
|
});
|
|
501
523
|
}
|
|
502
524
|
async function updateJobProgress(tenantId, jobId, progress, step) {
|
|
@@ -863,19 +885,7 @@ async function processJob(jobId, config, opts) {
|
|
|
863
885
|
skipUrls: [...config.skipUrls ?? [], ...checkpoint.visitedUrls ?? []]
|
|
864
886
|
}).catch((enqErr) => logger.warn({ err: enqErr?.message }, "[crawl] auth-failure auto-segment enqueue failed (non-fatal)"));
|
|
865
887
|
}
|
|
866
|
-
await
|
|
867
|
-
tenantId,
|
|
868
|
-
(tx) => tx.platformJob.update({
|
|
869
|
-
where: { id: jobId },
|
|
870
|
-
data: {
|
|
871
|
-
status: aborted ? "CANCELLED" : "FAILED",
|
|
872
|
-
error: aborted ? "Stopped by user" : String(err),
|
|
873
|
-
finishedAt: /* @__PURE__ */ new Date(),
|
|
874
|
-
...aborted ? {} : { nextRetryAt: new Date(Date.now() + 6e4) },
|
|
875
|
-
...checkpoint ? { result: { checkpoint } } : {}
|
|
876
|
-
}
|
|
877
|
-
})
|
|
878
|
-
);
|
|
888
|
+
await recordJobFailure(tenantId, jobId, err, checkpoint, { isAborted: aborted });
|
|
879
889
|
if (!aborted) {
|
|
880
890
|
notifyAdmins(tenantId, {
|
|
881
891
|
type: "crawl.failed",
|
|
@@ -952,10 +962,7 @@ async function processApiTestsJob(jobId, config) {
|
|
|
952
962
|
);
|
|
953
963
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
954
964
|
} catch (err) {
|
|
955
|
-
await
|
|
956
|
-
tenantId,
|
|
957
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) } })
|
|
958
|
-
);
|
|
965
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
959
966
|
}
|
|
960
967
|
}
|
|
961
968
|
async function processSecurityTestsJob(jobId, config) {
|
|
@@ -987,10 +994,7 @@ async function processSecurityTestsJob(jobId, config) {
|
|
|
987
994
|
);
|
|
988
995
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
989
996
|
} catch (err) {
|
|
990
|
-
await
|
|
991
|
-
tenantId,
|
|
992
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) } })
|
|
993
|
-
);
|
|
997
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
994
998
|
}
|
|
995
999
|
}
|
|
996
1000
|
async function processIntegrationTestsJob(jobId, config) {
|
|
@@ -1022,10 +1026,7 @@ async function processIntegrationTestsJob(jobId, config) {
|
|
|
1022
1026
|
);
|
|
1023
1027
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1024
1028
|
} catch (err) {
|
|
1025
|
-
await
|
|
1026
|
-
tenantId,
|
|
1027
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) } })
|
|
1028
|
-
);
|
|
1029
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1029
1030
|
}
|
|
1030
1031
|
}
|
|
1031
1032
|
async function processFigmaImportJob(jobId, config) {
|
|
@@ -1140,10 +1141,7 @@ async function processFigmaImportJob(jobId, config) {
|
|
|
1140
1141
|
}
|
|
1141
1142
|
});
|
|
1142
1143
|
} catch (err) {
|
|
1143
|
-
await
|
|
1144
|
-
tenantId,
|
|
1145
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
1146
|
-
);
|
|
1144
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1147
1145
|
}
|
|
1148
1146
|
}
|
|
1149
1147
|
async function processNativeCrawlJob(jobId, config) {
|
|
@@ -1234,11 +1232,7 @@ async function processNativeCrawlJob(jobId, config) {
|
|
|
1234
1232
|
}
|
|
1235
1233
|
});
|
|
1236
1234
|
} catch (err) {
|
|
1237
|
-
await
|
|
1238
|
-
tenantId,
|
|
1239
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
1240
|
-
).catch(() => {
|
|
1241
|
-
});
|
|
1235
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1242
1236
|
}
|
|
1243
1237
|
}
|
|
1244
1238
|
async function processGenerateTestsFromReqsJob(jobId, config) {
|
|
@@ -1248,7 +1242,7 @@ async function processGenerateTestsFromReqsJob(jobId, config) {
|
|
|
1248
1242
|
const { askTracked, deterministicSeed, getTaskAgentLLMConfig } = await import("@detiq/agents");
|
|
1249
1243
|
const llmCfg = await getTaskAgentLLMConfig(tenantId, "testGeneration");
|
|
1250
1244
|
const all = await AppBrain.listRequirements(tenantId, projectId, {});
|
|
1251
|
-
|
|
1245
|
+
let reqs = reqIds?.length ? all.filter((r) => reqIds.includes(r.reqId)) : all.filter((r) => r.status === "unbuilt" || r.status === "pending");
|
|
1252
1246
|
if (reqs.length === 0) {
|
|
1253
1247
|
await updateJobProgress(tenantId, jobId, 100, "No pending requirements found");
|
|
1254
1248
|
await withTenant(
|
|
@@ -1267,76 +1261,99 @@ async function processGenerateTestsFromReqsJob(jobId, config) {
|
|
|
1267
1261
|
});
|
|
1268
1262
|
resolvedSuiteId = suite.id;
|
|
1269
1263
|
}
|
|
1264
|
+
const existingJob = await prismaAdmin.platformJob.findUnique({
|
|
1265
|
+
where: { id: jobId },
|
|
1266
|
+
select: { checkpoint: true }
|
|
1267
|
+
}).catch(() => null);
|
|
1268
|
+
const completedReqIds = new Set(existingJob?.checkpoint?.completedReqIds ?? []);
|
|
1269
|
+
if (completedReqIds.size > 0) {
|
|
1270
|
+
reqs = reqs.filter((r) => !completedReqIds.has(r.reqId));
|
|
1271
|
+
}
|
|
1270
1272
|
await updateJobProgress(tenantId, jobId, 20, `Generating tests for ${reqs.length} requirements`);
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1273
|
+
const VALID_KINDS = /* @__PURE__ */ 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"]);
|
|
1274
|
+
const VALID_PRIORITIES = /* @__PURE__ */ new Set(["CRITICAL", "HIGH", "MEDIUM", "LOW"]);
|
|
1275
|
+
const CHUNK_SIZE = 5;
|
|
1276
|
+
let totalCreated = existingJob?.checkpoint?.createdCount ?? 0;
|
|
1277
|
+
let totalSkipped = existingJob?.checkpoint?.skippedCount ?? 0;
|
|
1278
|
+
const finishedReqIds = Array.from(completedReqIds);
|
|
1279
|
+
for (let i = 0; i < reqs.length; i += CHUNK_SIZE) {
|
|
1280
|
+
const chunk = reqs.slice(i, i + CHUNK_SIZE);
|
|
1281
|
+
const reqBlock = chunk.map((r) => [
|
|
1282
|
+
`## ${r.reqId}: ${r.title}`,
|
|
1283
|
+
`Priority: ${r.priority} | Type: ${r.type}`,
|
|
1284
|
+
r.acceptanceCriteria?.length ? `Acceptance Criteria:
|
|
1275
1285
|
${r.acceptanceCriteria.map((c) => `- ${c}`).join("\n")}` : "",
|
|
1276
|
-
|
|
1286
|
+
r.businessRules?.length ? `Business Rules:
|
|
1277
1287
|
${r.businessRules.map((b) => `- ${b}`).join("\n")}` : ""
|
|
1278
|
-
|
|
1279
|
-
|
|
1288
|
+
].filter(Boolean).join("\n")).join("\n\n");
|
|
1289
|
+
const system = `You are ZeTa-AI's test case generator. Convert requirements into structured test cases.
|
|
1280
1290
|
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 } ] }
|
|
1281
1291
|
Kind must be one of: POSITIVE, NEGATIVE, EDGE, SECURITY, ACCESSIBILITY, INTEGRATION, E2E, SMOKE, REGRESSION.
|
|
1282
1292
|
Priority must be one of: CRITICAL, HIGH, MEDIUM, LOW.
|
|
1283
1293
|
Generate 1-3 test cases per requirement (positive + negative + edge if applicable).
|
|
1284
1294
|
Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
1285
|
-
|
|
1295
|
+
const user = `Generate test cases for these requirements:
|
|
1286
1296
|
|
|
1287
1297
|
${reqBlock}`;
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1298
|
+
let parsed = { cases: [] };
|
|
1299
|
+
try {
|
|
1300
|
+
const reqSeed = deterministicSeed(`req-cases:${chunk.map((r) => r.reqId).sort().join("|").slice(0, 300)}`);
|
|
1301
|
+
const { text, usage } = await askTracked(system, user, llmCfg, void 0, void 0, { temperature: 0, seed: reqSeed });
|
|
1302
|
+
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: "generate_tests_from_reqs", label: "test_case_generation", usage }).catch(() => {
|
|
1303
|
+
});
|
|
1304
|
+
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
1305
|
+
if (json) parsed = JSON.parse(json);
|
|
1306
|
+
} catch (e) {
|
|
1307
|
+
throw new Error("AI failed to generate test cases: " + (e.message ?? "unknown"));
|
|
1308
|
+
}
|
|
1309
|
+
const normalized = (parsed.cases ?? []).map((c) => ({
|
|
1310
|
+
ref: c.reqRef ?? null,
|
|
1311
|
+
traceTo: c.reqRef ?? null,
|
|
1312
|
+
title: c.title || "Untitled",
|
|
1313
|
+
kind: VALID_KINDS.has((c.kind ?? "").toUpperCase()) ? c.kind.toUpperCase() : "POSITIVE",
|
|
1314
|
+
priority: VALID_PRIORITIES.has((c.priority ?? "").toUpperCase()) ? c.priority.toUpperCase() : "MEDIUM",
|
|
1315
|
+
preconditions: c.preconditions ?? "",
|
|
1316
|
+
steps: Array.isArray(c.steps) ? c.steps : [],
|
|
1317
|
+
expectedResult: c.expectedResult ?? "",
|
|
1318
|
+
tags: Array.isArray(c.tags) ? c.tags : []
|
|
1319
|
+
}));
|
|
1320
|
+
const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
|
|
1321
|
+
tenantId,
|
|
1322
|
+
projectId,
|
|
1323
|
+
resolvedSuiteId,
|
|
1324
|
+
normalized,
|
|
1325
|
+
{ duplicateMode: "skip" }
|
|
1326
|
+
);
|
|
1327
|
+
totalCreated += created.length;
|
|
1328
|
+
totalSkipped += skipped ?? 0;
|
|
1329
|
+
const coveredReqIds = new Set(normalized.map((c) => c.ref).filter(Boolean));
|
|
1330
|
+
await Promise.all(
|
|
1331
|
+
chunk.filter((r) => coveredReqIds.has(r.reqId)).map((r) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: "covered" }).catch(() => {
|
|
1332
|
+
}))
|
|
1333
|
+
);
|
|
1334
|
+
for (const r of chunk) finishedReqIds.push(r.reqId);
|
|
1335
|
+
await prismaAdmin.platformJob.update({
|
|
1336
|
+
where: { id: jobId },
|
|
1337
|
+
data: {
|
|
1338
|
+
checkpoint: { completedReqIds: finishedReqIds, createdCount: totalCreated, skippedCount: totalSkipped },
|
|
1339
|
+
checkpointAt: /* @__PURE__ */ new Date()
|
|
1340
|
+
}
|
|
1341
|
+
}).catch(() => {
|
|
1293
1342
|
});
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
} catch (e) {
|
|
1297
|
-
throw new Error("AI failed to generate test cases: " + (e.message ?? "unknown"));
|
|
1343
|
+
const progressPct = Math.round(20 + (i + chunk.length) / reqs.length * 75);
|
|
1344
|
+
await updateJobProgress(tenantId, jobId, progressPct, `Processed ${finishedReqIds.length}/${reqs.length} requirements (${totalCreated} cases generated)`);
|
|
1298
1345
|
}
|
|
1299
|
-
await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
|
|
1300
|
-
const VALID_KINDS = /* @__PURE__ */ 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"]);
|
|
1301
|
-
const VALID_PRIORITIES = /* @__PURE__ */ new Set(["CRITICAL", "HIGH", "MEDIUM", "LOW"]);
|
|
1302
|
-
const normalized = (parsed.cases ?? []).map((c) => ({
|
|
1303
|
-
ref: c.reqRef ?? null,
|
|
1304
|
-
traceTo: c.reqRef ?? null,
|
|
1305
|
-
title: c.title || "Untitled",
|
|
1306
|
-
kind: VALID_KINDS.has((c.kind ?? "").toUpperCase()) ? c.kind.toUpperCase() : "POSITIVE",
|
|
1307
|
-
priority: VALID_PRIORITIES.has((c.priority ?? "").toUpperCase()) ? c.priority.toUpperCase() : "MEDIUM",
|
|
1308
|
-
preconditions: c.preconditions ?? "",
|
|
1309
|
-
steps: Array.isArray(c.steps) ? c.steps : [],
|
|
1310
|
-
expectedResult: c.expectedResult ?? "",
|
|
1311
|
-
tags: Array.isArray(c.tags) ? c.tags : []
|
|
1312
|
-
}));
|
|
1313
|
-
const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
|
|
1314
|
-
tenantId,
|
|
1315
|
-
projectId,
|
|
1316
|
-
resolvedSuiteId,
|
|
1317
|
-
normalized,
|
|
1318
|
-
{ duplicateMode: "skip" }
|
|
1319
|
-
);
|
|
1320
|
-
const coveredReqIds = new Set(normalized.map((c) => c.ref).filter(Boolean));
|
|
1321
|
-
await Promise.all(
|
|
1322
|
-
reqs.filter((r) => coveredReqIds.has(r.reqId)).map((r) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: "covered" }).catch(() => {
|
|
1323
|
-
}))
|
|
1324
|
-
);
|
|
1325
1346
|
await withTenant(
|
|
1326
1347
|
tenantId,
|
|
1327
1348
|
(tx) => tx.platformJob.update({
|
|
1328
1349
|
where: { id: jobId },
|
|
1329
|
-
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { created:
|
|
1350
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { created: totalCreated, skipped: totalSkipped, suiteId: resolvedSuiteId }, progress: 100 }
|
|
1330
1351
|
})
|
|
1331
1352
|
);
|
|
1332
1353
|
} catch (e) {
|
|
1333
1354
|
const msg = e?.message ?? "Unknown error";
|
|
1334
1355
|
logger.error({ jobId, err: msg }, "processGenerateTestsFromReqsJob failed");
|
|
1335
|
-
await
|
|
1336
|
-
tenantId,
|
|
1337
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg } })
|
|
1338
|
-
).catch(() => {
|
|
1339
|
-
});
|
|
1356
|
+
await recordJobFailure(tenantId, jobId, e);
|
|
1340
1357
|
}
|
|
1341
1358
|
}
|
|
1342
1359
|
async function processRunTestSuiteJob(jobId, config) {
|
|
@@ -1373,6 +1390,17 @@ async function processRunTestSuiteJob(jobId, config) {
|
|
|
1373
1390
|
let passed = 0;
|
|
1374
1391
|
let failed = 0;
|
|
1375
1392
|
const runResults = [];
|
|
1393
|
+
const existingJob = await prismaAdmin.platformJob.findUnique({
|
|
1394
|
+
where: { id: jobId },
|
|
1395
|
+
select: { checkpoint: true }
|
|
1396
|
+
}).catch(() => null);
|
|
1397
|
+
const completedCaseIds = new Set(existingJob?.checkpoint?.completedCaseIds ?? []);
|
|
1398
|
+
if (completedCaseIds.size > 0) {
|
|
1399
|
+
testCases = testCases.filter((tc) => !completedCaseIds.has(tc.id));
|
|
1400
|
+
passed = existingJob?.checkpoint?.passed ?? 0;
|
|
1401
|
+
failed = existingJob?.checkpoint?.failed ?? 0;
|
|
1402
|
+
}
|
|
1403
|
+
const allFinishedCaseIds = Array.from(completedCaseIds);
|
|
1376
1404
|
for (let i = 0; i < testCases.length; i += TEST_EXEC_CONCURRENCY) {
|
|
1377
1405
|
const batch = testCases.slice(i, i + TEST_EXEC_CONCURRENCY);
|
|
1378
1406
|
await Promise.all(batch.map(async (tc) => {
|
|
@@ -1435,6 +1463,15 @@ async function processRunTestSuiteJob(jobId, config) {
|
|
|
1435
1463
|
});
|
|
1436
1464
|
}
|
|
1437
1465
|
}));
|
|
1466
|
+
for (const tc of batch) allFinishedCaseIds.push(tc.id);
|
|
1467
|
+
await prismaAdmin.platformJob.update({
|
|
1468
|
+
where: { id: jobId },
|
|
1469
|
+
data: {
|
|
1470
|
+
checkpoint: { completedCaseIds: allFinishedCaseIds, passed, failed },
|
|
1471
|
+
checkpointAt: /* @__PURE__ */ new Date()
|
|
1472
|
+
}
|
|
1473
|
+
}).catch(() => {
|
|
1474
|
+
});
|
|
1438
1475
|
await updateJobProgress(
|
|
1439
1476
|
tenantId,
|
|
1440
1477
|
jobId,
|
|
@@ -1470,11 +1507,7 @@ async function processRunTestSuiteJob(jobId, config) {
|
|
|
1470
1507
|
logger.error({ jobId, testRunId, err: msg }, "processRunTestSuiteJob failed");
|
|
1471
1508
|
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {
|
|
1472
1509
|
});
|
|
1473
|
-
await
|
|
1474
|
-
tenantId,
|
|
1475
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg } })
|
|
1476
|
-
).catch(() => {
|
|
1477
|
-
});
|
|
1510
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1478
1511
|
}
|
|
1479
1512
|
}
|
|
1480
1513
|
async function processGenerateSkillFilesJob(jobId, config) {
|
|
@@ -1515,10 +1548,7 @@ async function processGenerateSkillFilesJob(jobId, config) {
|
|
|
1515
1548
|
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date() } })
|
|
1516
1549
|
);
|
|
1517
1550
|
} catch (err) {
|
|
1518
|
-
await
|
|
1519
|
-
tenantId,
|
|
1520
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
1521
|
-
);
|
|
1551
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1522
1552
|
}
|
|
1523
1553
|
}
|
|
1524
1554
|
async function processDataRetentionPurge() {
|
|
@@ -1751,17 +1781,7 @@ async function processDiscoverJob(jobId, config, opts) {
|
|
|
1751
1781
|
}).catch(console.error);
|
|
1752
1782
|
} catch (err) {
|
|
1753
1783
|
logger.error({ err: err?.message, jobId }, "[discover] Job failed");
|
|
1754
|
-
await
|
|
1755
|
-
tenantId,
|
|
1756
|
-
(tx) => tx.platformJob.update({
|
|
1757
|
-
where: { id: jobId },
|
|
1758
|
-
data: {
|
|
1759
|
-
status: err?.message === "CRAWL_ABORTED" ? "CANCELLED" : "FAILED",
|
|
1760
|
-
result: { error: err?.message ?? String(err) },
|
|
1761
|
-
finishedAt: /* @__PURE__ */ new Date()
|
|
1762
|
-
}
|
|
1763
|
-
})
|
|
1764
|
-
).catch(console.error);
|
|
1784
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1765
1785
|
} finally {
|
|
1766
1786
|
clearInterval(heartbeatTimer);
|
|
1767
1787
|
runningControllers.delete(jobId);
|
|
@@ -1854,13 +1874,7 @@ async function processMonitorJob(jobId, config, opts) {
|
|
|
1854
1874
|
})
|
|
1855
1875
|
);
|
|
1856
1876
|
} catch (err) {
|
|
1857
|
-
await
|
|
1858
|
-
tenantId,
|
|
1859
|
-
(tx) => tx.platformJob.update({
|
|
1860
|
-
where: { id: jobId },
|
|
1861
|
-
data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() }
|
|
1862
|
-
})
|
|
1863
|
-
);
|
|
1877
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1864
1878
|
} finally {
|
|
1865
1879
|
runningControllers.delete(jobId);
|
|
1866
1880
|
}
|
|
@@ -2013,8 +2027,13 @@ async function processGenerateScreenTestsJob(jobId, config) {
|
|
|
2013
2027
|
await updateJobProgress(tenantId, jobId, 10, "Generating test cases for screen");
|
|
2014
2028
|
const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null);
|
|
2015
2029
|
const deviceProfiles = crawlCfg?.deviceProfiles ?? ["DESKTOP"];
|
|
2030
|
+
const existingAiCases = await withTenant(
|
|
2031
|
+
tenantId,
|
|
2032
|
+
(tx) => tx.testCase.count({ where: { tenantId, screenId, source: "AI_GENERATED" } })
|
|
2033
|
+
);
|
|
2034
|
+
const generationMode = existingAiCases > 0 ? "replace" : void 0;
|
|
2016
2035
|
for (const viewport of deviceProfiles) {
|
|
2017
|
-
await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport });
|
|
2036
|
+
await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport, ...generationMode ? { mode: generationMode } : {} });
|
|
2018
2037
|
}
|
|
2019
2038
|
await updateJobProgress(tenantId, jobId, 80, "Test cases generated");
|
|
2020
2039
|
if (triggerAlignedScripts && repoConnectionId) {
|
|
@@ -2050,11 +2069,7 @@ async function processGenerateScreenTestsJob(jobId, config) {
|
|
|
2050
2069
|
} catch (err) {
|
|
2051
2070
|
const msg = err?.message ?? String(err);
|
|
2052
2071
|
logger.error({ jobId, screenId, err: msg }, "processGenerateScreenTestsJob failed");
|
|
2053
|
-
await
|
|
2054
|
-
tenantId,
|
|
2055
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: msg.slice(0, 500), finishedAt: /* @__PURE__ */ new Date() } })
|
|
2056
|
-
).catch(() => {
|
|
2057
|
-
});
|
|
2072
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2058
2073
|
}
|
|
2059
2074
|
}
|
|
2060
2075
|
function captureStepText(step) {
|
|
@@ -2288,13 +2303,8 @@ async function processCaptureStepsJob(jobId, config) {
|
|
|
2288
2303
|
);
|
|
2289
2304
|
} catch (err) {
|
|
2290
2305
|
const error = String(err?.message ?? err);
|
|
2291
|
-
await withTenant(
|
|
2292
|
-
tenantId,
|
|
2293
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error, finishedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date() } })
|
|
2294
|
-
).catch(() => {
|
|
2295
|
-
});
|
|
2296
2306
|
logger.error({ jobId, testCaseId, err: error }, "processCaptureStepsJob failed");
|
|
2297
|
-
|
|
2307
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2298
2308
|
} finally {
|
|
2299
2309
|
await browser?.close?.().catch(() => {
|
|
2300
2310
|
});
|
|
@@ -2430,11 +2440,7 @@ async function processMultiRoleCrawlJob(jobId, config) {
|
|
|
2430
2440
|
} catch (err) {
|
|
2431
2441
|
const msg = String(err?.message ?? err);
|
|
2432
2442
|
logger.error({ jobId, err: msg }, "[multi-role-crawl] failed");
|
|
2433
|
-
await
|
|
2434
|
-
tenantId,
|
|
2435
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: msg, finishedAt: /* @__PURE__ */ new Date() } })
|
|
2436
|
-
).catch(() => {
|
|
2437
|
-
});
|
|
2443
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2438
2444
|
}
|
|
2439
2445
|
}
|
|
2440
2446
|
async function processRoleCrawlJob(jobId, config) {
|
|
@@ -2522,11 +2528,7 @@ async function processRoleCrawlJob(jobId, config) {
|
|
|
2522
2528
|
} catch (err) {
|
|
2523
2529
|
const msg = String(err?.message ?? err);
|
|
2524
2530
|
logger.error({ jobId, err: msg }, "[role-crawl] outer failure");
|
|
2525
|
-
await
|
|
2526
|
-
tenantId,
|
|
2527
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: msg, finishedAt: /* @__PURE__ */ new Date() } })
|
|
2528
|
-
).catch(() => {
|
|
2529
|
-
});
|
|
2531
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2530
2532
|
}
|
|
2531
2533
|
}
|
|
2532
2534
|
async function processGenerateScriptJob(jobId, config) {
|
|
@@ -2671,11 +2673,7 @@ async function processArtifactIntelJob(jobId, config) {
|
|
|
2671
2673
|
(tx) => tx.projectArtifact.update({ where: { id: artifactId }, data: { status: "ERROR", summary: "AI analysis failed" } })
|
|
2672
2674
|
).catch(() => {
|
|
2673
2675
|
});
|
|
2674
|
-
await
|
|
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
|
-
});
|
|
2676
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2679
2677
|
queueImportedArtifactSkillRefresh(tenantId, projectId, "artifact-intel-failed");
|
|
2680
2678
|
}
|
|
2681
2679
|
}
|
|
@@ -3183,11 +3181,7 @@ async function processRepoScanJob(jobId, config) {
|
|
|
3183
3181
|
(tx) => tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: "FAILED", scanError: msg.slice(0, 500) } })
|
|
3184
3182
|
).catch(() => {
|
|
3185
3183
|
});
|
|
3186
|
-
await
|
|
3187
|
-
tenantId,
|
|
3188
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
3189
|
-
).catch(() => {
|
|
3190
|
-
});
|
|
3184
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3191
3185
|
}
|
|
3192
3186
|
}
|
|
3193
3187
|
async function processBatchGenerateAlignedJob(jobId, config) {
|
|
@@ -3199,7 +3193,6 @@ async function processBatchGenerateAlignedJob(jobId, config) {
|
|
|
3199
3193
|
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
3200
3194
|
);
|
|
3201
3195
|
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
3202
|
-
const { decrypt: decrypt2 } = await import("@detiq/core");
|
|
3203
3196
|
const {
|
|
3204
3197
|
generateScriptAligned,
|
|
3205
3198
|
deriveRepoTargetPath,
|
|
@@ -3298,11 +3291,7 @@ async function processBatchGenerateAlignedJob(jobId, config) {
|
|
|
3298
3291
|
} catch (err) {
|
|
3299
3292
|
const msg = String(err?.message ?? err);
|
|
3300
3293
|
logger.error({ jobId, err: msg }, "[batch-generate-aligned] failed");
|
|
3301
|
-
await
|
|
3302
|
-
tenantId,
|
|
3303
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
3304
|
-
).catch(() => {
|
|
3305
|
-
});
|
|
3294
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3306
3295
|
}
|
|
3307
3296
|
}
|
|
3308
3297
|
async function processCreatePRJob(jobId, config) {
|
|
@@ -3408,11 +3397,7 @@ async function processCreatePRJob(jobId, config) {
|
|
|
3408
3397
|
} catch (err) {
|
|
3409
3398
|
const msg = String(err?.message ?? err);
|
|
3410
3399
|
logger.error({ jobId, err: msg }, "[create-pr] failed");
|
|
3411
|
-
await
|
|
3412
|
-
tenantId,
|
|
3413
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
3414
|
-
).catch(() => {
|
|
3415
|
-
});
|
|
3400
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3416
3401
|
}
|
|
3417
3402
|
}
|
|
3418
3403
|
function buildPRBody(scripts, projectId) {
|
|
@@ -3436,7 +3421,7 @@ function buildPRBody(scripts, projectId) {
|
|
|
3436
3421
|
return lines.join("\n");
|
|
3437
3422
|
}
|
|
3438
3423
|
async function processNewPagesPipelineJob(jobId, config) {
|
|
3439
|
-
const { tenantId, projectId, crawlJobId, repoConnectionId, autoPR = false } = config;
|
|
3424
|
+
const { tenantId, projectId, crawlJobId: _crawlJobId, repoConnectionId, autoPR = false } = config;
|
|
3440
3425
|
try {
|
|
3441
3426
|
await updateJobProgress(tenantId, jobId, 5, "Detecting new screens");
|
|
3442
3427
|
const conn = await withTenant(
|
|
@@ -3504,11 +3489,7 @@ async function processNewPagesPipelineJob(jobId, config) {
|
|
|
3504
3489
|
} catch (err) {
|
|
3505
3490
|
const msg = String(err?.message ?? err);
|
|
3506
3491
|
logger.error({ jobId, err: msg }, "[new-pages-pipeline] failed");
|
|
3507
|
-
await
|
|
3508
|
-
tenantId,
|
|
3509
|
-
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
3510
|
-
).catch(() => {
|
|
3511
|
-
});
|
|
3492
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3512
3493
|
}
|
|
3513
3494
|
}
|
|
3514
3495
|
export {
|
|
@@ -3529,6 +3510,7 @@ export {
|
|
|
3529
3510
|
processDataRetentionPurge,
|
|
3530
3511
|
processJob,
|
|
3531
3512
|
promoteToDeadLetterQueue,
|
|
3513
|
+
recordJobFailure,
|
|
3532
3514
|
requeueOrphanedJobs,
|
|
3533
3515
|
requeueStaleRunningJobs,
|
|
3534
3516
|
rerunJob,
|