@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/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
- const members = await withTenant(
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 { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from "@detiq/app-brain";
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
- ORDER BY "queuedAt" ASC
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: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date(), nextRetryAt: new Date(Date.now() + 6e4) }
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 = 'QUEUED',
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" <= NOW()
213
- AND "retryCount" < CASE
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] requeued failed job(s) for retry");
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 tick = async () => {
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
- try {
258
- const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: "queue.paused" } });
259
- if (pauseRow?.value === "true") {
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
- } else {
262
- for (let i = 0; i < concurrency; i++) {
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
- } catch (err) {
269
- logger.error({ err: String(err) }, "[job-worker] claim loop failed");
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
- workerLoopTimer = setInterval(tick, pollIntervalMs);
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
- const nextRetryAt = error ? new Date(Date.now() + 6e4) : void 0;
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: error ? "FAILED" : "DONE",
390
+ status: nextRetryAt ? "RETRY_WAIT" : "FAILED",
300
391
  finishedAt: /* @__PURE__ */ new Date(),
301
- ...error ? { error, nextRetryAt } : { progress: 100 }
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: "CANCELLED", finishedAt: /* @__PURE__ */ new Date(), heartbeatAt: null, workerId: null }
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
- data: { progress, result: { step }, heartbeatAt: /* @__PURE__ */ new Date() }
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(`${tenantId}:${projectId}:req-cases:${reqs.map((r) => r.reqId).sort().join("|").slice(0, 300)}`);
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,