@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.
@@ -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 result2 = await askTrackedWithImages("", userText, [{ base64: img.toString("base64"), mediaType: "image/png" }], llmCfg);
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;AAC5H,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;",
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
- 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);
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: visitedUrls.length > 0 ? visitedUrls : void 0,
499
- resumeUrls: pendingQueue.length > 0 ? pendingQueue : void 0
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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
- const reqs = reqIds?.length ? all.filter((r) => reqIds.includes(r.reqId)) : all.filter((r) => r.status === "unbuilt" || r.status === "pending");
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 reqBlock = reqs.map((r) => [
1272
- `## ${r.reqId}: ${r.title}`,
1273
- `Priority: ${r.priority} | Type: ${r.type}`,
1274
- r.acceptanceCriteria?.length ? `Acceptance Criteria:
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
- r.businessRules?.length ? `Business Rules:
1286
+ r.businessRules?.length ? `Business Rules:
1277
1287
  ${r.businessRules.map((b) => `- ${b}`).join("\n")}` : ""
1278
- ].filter(Boolean).join("\n")).join("\n\n");
1279
- const system = `You are ZeTa-AI's test case generator. Convert requirements into structured test cases.
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
- const user = `Generate test cases for these requirements:
1295
+ const user = `Generate test cases for these requirements:
1286
1296
 
1287
1297
  ${reqBlock}`;
1288
- let parsed = { cases: [] };
1289
- try {
1290
- const reqSeed = deterministicSeed(`req-cases:${reqs.map((r) => r.reqId).sort().join("|").slice(0, 300)}`);
1291
- const { text, usage } = await askTracked(system, user, llmCfg, void 0, void 0, { temperature: 0, seed: reqSeed });
1292
- if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: "generate_tests_from_reqs", label: "test_case_generation", usage }).catch(() => {
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 json = text.match(/\{[\s\S]*\}/)?.[0];
1295
- if (json) parsed = JSON.parse(json);
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: created.length, skipped, suiteId: resolvedSuiteId }, progress: 100 }
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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
- throw err;
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 withTenant(
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 withTenant(
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 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
- });
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 withTenant(
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 withTenant(
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 withTenant(
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 withTenant(
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,