@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.26",
3
+ "version": "1.1.27",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/crawler.ts CHANGED
@@ -373,7 +373,6 @@ async function launchChromeCDP(
373
373
  '--remote-allow-origins=*',
374
374
  '--no-first-run',
375
375
  '--no-default-browser-check',
376
- '--window-size=1280,900',
377
376
  ],
378
377
  });
379
378
 
@@ -634,6 +633,10 @@ export async function crawlProject(opts: {
634
633
  /** When false, disables the puppeteer-extra stealth plugin (playwright-extra) for this crawl.
635
634
  * Default true — stealth is applied unless explicitly set to false. */
636
635
  stealth?: boolean;
636
+ /** Desktop viewport width in pixels (default: 1440). */
637
+ viewportWidth?: number;
638
+ /** Desktop viewport height in pixels (default: 900). */
639
+ viewportHeight?: number;
637
640
  /** Called every 10 successful screen captures with current visited URLs and remaining queue — used for checkpoint persistence. */
638
641
  onCheckpoint?: (visitedUrls: string[], pendingQueue: string[]) => Promise<void>;
639
642
  /** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
@@ -829,7 +832,7 @@ export async function crawlProject(opts: {
829
832
  '--disable-blink-features=AutomationControlled',
830
833
  '--disable-http2',
831
834
  '--lang=en-US,en',
832
- '--window-size=1366,768',
835
+ `--window-size=${opts.viewportWidth ?? 1440},${opts.viewportHeight ?? 900}`,
833
836
  '--disable-features=IsolateOrigins,site-per-process',
834
837
  ];
835
838
 
@@ -36,7 +36,8 @@ Error: ${ctx.error}${consoleSection}${networkSection}`;
36
36
  if (ctx.screenshotPath) {
37
37
  try {
38
38
  const img = readFileSync(ctx.screenshotPath);
39
- const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg);
39
+ const imgSeed = deterministicSeed(`failure-img:${ctx.tenantId ?? ''}:${(ctx.stepDescription ?? '').slice(0, 100)}:${ctx.error.slice(0, 200)}`);
40
+ const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg, { temperature: 0, seed: imgSeed });
40
41
  return result.text || null;
41
42
  } catch { /* fall through to text-only */ }
42
43
  }
package/src/job-queue.ts CHANGED
@@ -18,40 +18,18 @@ import crypto from 'node:crypto';
18
18
  import { withTenant, prismaAdmin } from '@detiq/database';
19
19
  import { crawlJobsTotal, crawlJobDurationSeconds } from '@detiq/core';
20
20
  import { logger } from './logger.js';
21
+ import { AppBrain, getProjectStorageConfig, buildArtifactKey, uploadArtifact, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall, downloadBufferFromTenantStorage, dispatchToTenantAdmins } from '@detiq/app-brain';
21
22
 
22
23
  async function notifyAdmins(tenantId: string, data: {
23
24
  type: string; title: string; body: string; metadata?: Record<string, unknown>
24
25
  }) {
25
26
  try {
26
- // Notification/Membership both have FORCE ROW LEVEL SECURITY — a bare
27
- // prisma call with no app.current_tenant set gets silently filtered to
28
- // zero rows (SELECT) or rejected (INSERT), not an error, so this call
29
- // site had been a complete no-op with no visible failure until fixed.
30
- const members = await withTenant(tenantId, (tx) =>
31
- tx.membership.findMany({
32
- where: { tenantId, role: { in: ['OWNER', 'ADMIN'] } },
33
- select: { userId: true },
34
- })
35
- ) as any[];
36
- await withTenant(tenantId, (tx) =>
37
- tx.notification.createMany({
38
- data: members.map((m: { userId: string }) => ({
39
- id: `notif_${crypto.randomUUID()}`,
40
- tenantId,
41
- userId: m.userId,
42
- type: data.type,
43
- title: data.title,
44
- body: data.body,
45
- metadata: data.metadata as any,
46
- })),
47
- })
48
- );
27
+ await dispatchToTenantAdmins(tenantId, data as any);
49
28
  } catch (e) {
50
29
  console.error('[notify] failed:', e);
51
30
  }
52
31
  }
53
- import { AppBrain, getProjectStorageConfig, buildArtifactKey, uploadArtifact, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from '@detiq/app-brain';
54
- import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from '@detiq/agents';
32
+ import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog, generateArtifactIntelligence } from '@detiq/agents';
55
33
  import type { ExecutionStep } from '@detiq/agents';
56
34
  import { crawlProject, domFallbackElements } from './crawler.js';
57
35
  import { httpCrawl } from './http-engine.js';
@@ -1688,7 +1666,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1688
1666
 
1689
1667
  let parsed: { cases: any[] } = { cases: [] };
1690
1668
  try {
1691
- const reqSeed = deterministicSeed(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
1669
+ const reqSeed = deterministicSeed(`req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
1692
1670
  const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
1693
1671
  if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
1694
1672
  const json = text.match(/\{[\s\S]*\}/)?.[0];
@@ -2561,8 +2539,13 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2561
2539
  const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null);
2562
2540
  const deviceProfiles: string[] = (crawlCfg as any)?.deviceProfiles ?? ['DESKTOP'];
2563
2541
 
2542
+ const existingAiCases: number = await withTenant(tenantId, (tx) =>
2543
+ (tx as any).testCase.count({ where: { tenantId, screenId, source: 'AI_GENERATED' } })
2544
+ );
2545
+ const generationMode = existingAiCases > 0 ? 'replace' : undefined;
2546
+
2564
2547
  for (const viewport of deviceProfiles) {
2565
- await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport });
2548
+ await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport, ...(generationMode ? { mode: generationMode } : {}) });
2566
2549
  }
2567
2550
 
2568
2551
  await updateJobProgress(tenantId, jobId, 80, 'Test cases generated');
@@ -3197,13 +3180,111 @@ async function processGenerateScriptJob(jobId: string, config: any): Promise<voi
3197
3180
  }
3198
3181
  }
3199
3182
 
3183
+ function queueImportedArtifactSkillRefresh(tenantId: string, projectId: string, reason: string): void {
3184
+ setImmediate(() => {
3185
+ import('@detiq/agents')
3186
+ .then((agentsModule) => {
3187
+ const agents = agentsModule as any;
3188
+ return Promise.allSettled([
3189
+ agents.refreshArtifactKnowledgeSkillFile(tenantId, projectId),
3190
+ agents.refreshRequirementsCoverageSkillFile(tenantId, projectId),
3191
+ ]);
3192
+ })
3193
+ .then((results) => {
3194
+ results?.forEach((result) => {
3195
+ if (result.status === 'rejected') {
3196
+ console.warn(`[artifacts] skill refresh failed after ${reason}:`, result.reason?.message ?? result.reason);
3197
+ }
3198
+ });
3199
+ })
3200
+ .catch((e) => console.warn(`[artifacts] skill refresh failed after ${reason}:`, e?.message ?? e));
3201
+ });
3202
+ }
3203
+
3200
3204
  // Registers the type→processor dispatch table used by the durable worker (both the
3201
3205
  // enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
3202
3206
  // bottom so it can reference every process*Job function by name; function
3203
3207
  // declarations are hoisted, so this could technically live anywhere in the file,
3204
3208
  // but keeping it here alongside the last definition keeps the mapping easy to audit
3205
3209
  // against the actual functions above.
3210
+ const CONTENT_DETECTED_DOC_TYPES = new Set(['FUNCTIONIZE_TC', 'SELENIUM_SCRIPT', 'ENV_CONFIG']);
3211
+ const AI_DOC_TYPES = new Set([
3212
+ 'REQUIREMENTS', 'USER_STORY', 'TEST_CASES', 'TEST_DATA', 'AUTOMATION_SCRIPT', 'API_SPEC', 'ARCHITECTURE', 'REPORT', 'OTHER',
3213
+ 'SPREADSHEET', 'DATASHEET',
3214
+ ]);
3215
+ const AI_DOC_TYPE_ALIASES: Record<string, string> = {
3216
+ REQUIREMENT: 'REQUIREMENTS', SPEC: 'REQUIREMENTS', SPECIFICATION: 'REQUIREMENTS',
3217
+ USER_STORIES: 'USER_STORY', STORY: 'USER_STORY',
3218
+ TEST_CASE: 'TEST_CASES', TESTCASES: 'TEST_CASES',
3219
+ TESTDATA: 'TEST_DATA', SCRIPT: 'AUTOMATION_SCRIPT', AUTOMATION: 'AUTOMATION_SCRIPT',
3220
+ };
3221
+ function normalizeAiDocType(raw: unknown): string {
3222
+ const key = String(raw ?? '').trim().toUpperCase().replace(/[\s-]+/g, '_');
3223
+ const mapped = AI_DOC_TYPE_ALIASES[key] ?? key;
3224
+ return AI_DOC_TYPES.has(mapped) ? mapped : 'OTHER';
3225
+ }
3226
+
3227
+ async function processArtifactIntelJob(jobId: string, config: any) {
3228
+ const { tenantId, projectId, artifactId, artifactName, isReprocess } = config;
3229
+ try {
3230
+ await updateJobProgress(tenantId, jobId, 10, 'Downloading artifact content');
3231
+
3232
+ const artifact = await withTenant(tenantId, (tx) =>
3233
+ (tx as any).projectArtifact.findUnique({ where: { id: artifactId }, select: { storageKey: true, docType: true } })
3234
+ ) as any;
3235
+
3236
+ if (!artifact || !artifact.storageKey) {
3237
+ await updateJobProgress(tenantId, jobId, 100, 'Artifact deleted or missing content, skipping.');
3238
+ await withTenant(tenantId, (tx) =>
3239
+ (tx as any).platformJob.update({ where: { id: jobId }, data: { status: 'DONE', finishedAt: new Date() } })
3240
+ );
3241
+ return;
3242
+ }
3243
+
3244
+ const storageCfg = await getTenantStorageConfig(tenantId);
3245
+ if (!storageCfg) {
3246
+ throw new Error('Tenant storage configuration missing.');
3247
+ }
3248
+ const contentBuffer = await downloadBufferFromTenantStorage(storageCfg, artifact.storageKey);
3249
+ const content = contentBuffer.toString('utf8');
3250
+
3251
+ await updateJobProgress(tenantId, jobId, 40, 'Generating intelligence');
3252
+
3253
+ const intel = await generateArtifactIntelligence(tenantId, projectId, artifactId, content, artifactName, jobId);
3254
+
3255
+ const finalDocType = artifact.docType && CONTENT_DETECTED_DOC_TYPES.has(artifact.docType)
3256
+ ? artifact.docType
3257
+ : normalizeAiDocType(intel.docType);
3258
+
3259
+ if (finalDocType !== intel.docType) {
3260
+ await withTenant(tenantId, (tx) =>
3261
+ (tx as any).projectArtifact.update({ where: { id: artifactId }, data: { docType: finalDocType } })
3262
+ ).catch((err: any) => console.warn(`[artifact-intel] docType fix-up failed for "${artifactName}":`, err?.message));
3263
+ intel.docType = finalDocType;
3264
+ }
3265
+
3266
+ await updateJobProgress(tenantId, jobId, 90, 'Saving results');
3267
+
3268
+ await withTenant(tenantId, (tx) =>
3269
+ (tx as any).platformJob.update({ where: { id: jobId }, data: { result: { artifactId, artifactName, summary: intel.summary, keywords: intel.keywords, docType: intel.docType }, status: 'DONE', finishedAt: new Date(), progress: 100 } })
3270
+ );
3271
+
3272
+ queueImportedArtifactSkillRefresh(tenantId, projectId, isReprocess ? 'artifact-reprocess' : 'artifact-intel');
3273
+ } catch (err: any) {
3274
+ const msg = String(err?.message ?? err);
3275
+ logger.error({ jobId, err: msg }, '[artifact-intel] failed');
3276
+ await withTenant(tenantId, (tx) =>
3277
+ (tx as any).projectArtifact.update({ where: { id: artifactId }, data: { status: 'ERROR', summary: 'AI analysis failed' } })
3278
+ ).catch(() => { });
3279
+ await withTenant(tenantId, (tx) =>
3280
+ (tx as any).platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3281
+ ).catch(() => { });
3282
+ queueImportedArtifactSkillRefresh(tenantId, projectId, 'artifact-intel-failed');
3283
+ }
3284
+ }
3285
+
3206
3286
  jobDispatchTable = {
3287
+ ARTIFACT_INTEL: processArtifactIntelJob,
3207
3288
  CRAWL: processJob,
3208
3289
  CAPTURE_STEPS: processCaptureStepsJob,
3209
3290
  DISCOVER: processDiscoverJob,
@@ -3726,8 +3807,6 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
3726
3807
  ) as any;
3727
3808
  if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
3728
3809
 
3729
- const { decrypt } = await import('@detiq/core');
3730
-
3731
3810
  const {
3732
3811
  generateScriptAligned, deriveRepoTargetPath, validateGeneratedScript,
3733
3812
  mapWithConcurrency, SCRIPT_GEN_CONCURRENCY,
@@ -3992,7 +4071,7 @@ function buildPRBody(scripts: any[], projectId: string): string {
3992
4071
  // ── NEW_PAGES_PIPELINE — post-crawl: detect new screens, generate tests + scripts ──
3993
4072
 
3994
4073
  async function processNewPagesPipelineJob(jobId: string, config: any) {
3995
- const { tenantId, projectId, crawlJobId, repoConnectionId, autoPR = false } = config;
4074
+ const { tenantId, projectId, crawlJobId: _crawlJobId, repoConnectionId, autoPR = false } = config;
3996
4075
  try {
3997
4076
  await updateJobProgress(tenantId, jobId, 5, 'Detecting new screens');
3998
4077
 
@@ -167,6 +167,10 @@ export declare function crawlProject(opts: {
167
167
  /** When false, disables the puppeteer-extra stealth plugin (playwright-extra) for this crawl.
168
168
  * Default true — stealth is applied unless explicitly set to false. */
169
169
  stealth?: boolean;
170
+ /** Desktop viewport width in pixels (default: 1440). */
171
+ viewportWidth?: number;
172
+ /** Desktop viewport height in pixels (default: 900). */
173
+ viewportHeight?: number;
170
174
  /** Called every 10 successful screen captures with current visited URLs and remaining queue — used for checkpoint persistence. */
171
175
  onCheckpoint?: (visitedUrls: string[], pendingQueue: string[]) => Promise<void>;
172
176
  /** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
@@ -13,6 +13,13 @@
13
13
  * (JOB_QUEUE_BACKEND=sqs, see sqs-queue.ts) for horizontally-scaled crawler
14
14
  * fleets; every other job type always goes through the DB-backed queue.
15
15
  */
16
+ export declare enum FailureType {
17
+ TRANSIENT = "TRANSIENT",// network timeout, rate limit, 429, 503
18
+ INFRASTRUCTURE = "INFRASTRUCTURE",// DB connection, OOM, worker crash
19
+ LOGICAL = "LOGICAL",// bad input, business rule violation, 400/422
20
+ FATAL = "FATAL"
21
+ }
22
+ export declare function classifyFailure(error: string | Error | unknown): FailureType;
16
23
  export declare function generateTOTP(secret: string): string;
17
24
  export interface PlatformJobConfig {
18
25
  tenantId: string;
@@ -114,6 +121,7 @@ export interface PlatformJobConfig {
114
121
  /** Set on role-crawl sub-jobs — profile name tagged on discovered screens via roleAccess column. */
115
122
  roleProfileName?: string;
116
123
  }
124
+ export declare function getActiveExecutionCount(): number;
117
125
  type ClaimedJob = {
118
126
  id: string;
119
127
  tenantId: string;
@@ -144,7 +152,9 @@ export declare function requeueOrphanedJobs(): Promise<void>;
144
152
  /** Dispatches a claimed job to its type's processor. Last-resort safety net — every
145
153
  * process*Job function already catches and records its own failures. */
146
154
  export declare function dispatchClaimedJob(job: ClaimedJob): Promise<void>;
147
- /** Re-promotes FAILED jobs to QUEUED if they are eligible for retry based on per-type limits. */
155
+ /** Re-promotes FAILED jobs to RETRY_WAIT if they are eligible for retry based on per-type limits.
156
+ * RETRY_WAIT makes the waiting period visible in the UI. A separate sweep then promotes
157
+ * RETRY_WAIT → QUEUED once nextRetryAt <= NOW(). */
148
158
  export declare function retryEligibleFailedJobs(): Promise<void>;
149
159
  /** Promotes FAILED jobs to DLQ if they exceed their per-type retry limits. */
150
160
  export declare function promoteToDeadLetterQueue(): Promise<void>;
@@ -169,7 +179,7 @@ export declare function createTrackedJob(type: string, config: {
169
179
  [k: string]: any;
170
180
  }): Promise<string>;
171
181
  /** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors.
172
- * Gap 30: When marking FAILED, sets nextRetryAt (1 min) so the retry worker picks it up promptly. */
182
+ * GAP-14/15: Uses failure-type classification for per-type backoff on FAILED. */
173
183
  export declare function finishTrackedJob(tenantId: string, jobId: string, error?: string): Promise<void>;
174
184
  /** Enqueue a generic platform job — returns the job ID immediately. */
175
185
  export declare function enqueueJob(type: string, config: any): Promise<string>;
@@ -184,8 +194,10 @@ export declare function getJob(tenantId: string, jobId: string): Promise<{
184
194
  status: string;
185
195
  tenantId: string;
186
196
  projectId: string;
197
+ inline: boolean;
187
198
  config: import("@prisma/client/runtime/library").JsonValue;
188
199
  retryCount: number;
200
+ failureType: string | null;
189
201
  queuedAt: Date;
190
202
  startedAt: Date | null;
191
203
  finishedAt: Date | null;
@@ -199,6 +211,18 @@ export declare function getJob(tenantId: string, jobId: string): Promise<{
199
211
  nextRetryAt: Date | null;
200
212
  dlq: boolean;
201
213
  dlqReason: string | null;
214
+ leaseExpiresAt: Date | null;
215
+ fencingToken: string | null;
216
+ checkpoint: import("@prisma/client/runtime/library").JsonValue | null;
217
+ checkpointAt: Date | null;
218
+ parentJobId: string | null;
219
+ rootJobId: string | null;
220
+ currentStep: string | null;
221
+ requeuedAt: Date | null;
222
+ dlqAt: Date | null;
223
+ dlqRedriveCount: number;
224
+ submittedBy: string | null;
225
+ priority: number;
202
226
  } | null>;
203
227
  /** List recent jobs for a project. */
204
228
  export declare function listJobs(tenantId: string, projectId: string): Promise<{
@@ -211,8 +235,10 @@ export declare function listJobs(tenantId: string, projectId: string): Promise<{
211
235
  status: string;
212
236
  tenantId: string;
213
237
  projectId: string;
238
+ inline: boolean;
214
239
  config: import("@prisma/client/runtime/library").JsonValue;
215
240
  retryCount: number;
241
+ failureType: string | null;
216
242
  queuedAt: Date;
217
243
  startedAt: Date | null;
218
244
  finishedAt: Date | null;
@@ -226,8 +252,23 @@ export declare function listJobs(tenantId: string, projectId: string): Promise<{
226
252
  nextRetryAt: Date | null;
227
253
  dlq: boolean;
228
254
  dlqReason: string | null;
255
+ leaseExpiresAt: Date | null;
256
+ fencingToken: string | null;
257
+ checkpoint: import("@prisma/client/runtime/library").JsonValue | null;
258
+ checkpointAt: Date | null;
259
+ parentJobId: string | null;
260
+ rootJobId: string | null;
261
+ currentStep: string | null;
262
+ requeuedAt: Date | null;
263
+ dlqAt: Date | null;
264
+ dlqRedriveCount: number;
265
+ submittedBy: string | null;
266
+ priority: number;
229
267
  }[]>;
230
- /** Cancel a RUNNING or QUEUED job. Aborts the in-process crawl and marks DB CANCELLED. */
268
+ /** Cancel a RUNNING or QUEUED job.
269
+ * Sets status to CANCEL_REQUESTED (GAP-08) so the worker's abort handler can
270
+ * detect the request and set CANCELLED + finishedAt after the underlying work stops.
271
+ * The in-process AbortController is also triggered for same-process jobs. */
231
272
  export declare function cancelJob(tenantId: string, jobId: string): Promise<void>;
232
273
  /** Re-enqueue a job using the same config as a previous job. */
233
274
  export declare function rerunJob(tenantId: string, jobId: string): Promise<string>;