@detiq/crawler 1.1.18 → 1.1.20

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/src/job-queue.ts CHANGED
@@ -240,8 +240,25 @@ export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
240
240
 
241
241
  /** Requeues RUNNING jobs whose heartbeat has gone stale — the worker that claimed them
242
242
  * died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and
243
- * safe to run from every worker process; only affects rows past the timeout. */
243
+ * safe to run from every worker process; only affects rows past the timeout.
244
+ * API-in-process job types (GENERATE_SCRIPT etc.) are failed directly — requeueing them
245
+ * would cause the worker to claim and fail them with a confusing "claimed by worker" error. */
244
246
  export async function requeueStaleRunningJobs(): Promise<void> {
247
+ // Directly fail stale API-in-process jobs — worker cannot process these types.
248
+ const failedRows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
249
+ UPDATE "PlatformJob"
250
+ SET status = 'FAILED',
251
+ "finishedAt" = NOW(),
252
+ error = 'API process was restarted while this job was running — please retry from the UI.'
253
+ WHERE status = 'RUNNING'
254
+ AND type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
255
+ AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
256
+ RETURNING id
257
+ `;
258
+ if (failedRows.length > 0) {
259
+ logger.warn({ jobIds: failedRows.map((r: any) => r.id), staleTimeoutMinutes: STALE_JOB_TIMEOUT_MINUTES }, '[job-worker] failed stale API-in-process job(s) — API restarted while running');
260
+ }
261
+
245
262
  const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
246
263
  UPDATE "PlatformJob"
247
264
  SET status = 'QUEUED',
@@ -253,6 +270,7 @@ export async function requeueStaleRunningJobs(): Promise<void> {
253
270
  true
254
271
  )
255
272
  WHERE status = 'RUNNING'
273
+ AND type NOT IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
256
274
  AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
257
275
  RETURNING id
258
276
  `;
@@ -691,8 +709,20 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
691
709
  jobCheckpoints.delete(jobId);
692
710
 
693
711
  const pendingUrls: string[] = (result as any)?.pendingUrls ?? [];
712
+ let nextJobId: string | undefined;
694
713
  if (pendingUrls.length > 0) {
695
- logger.info({ pendingUrls: pendingUrls.length }, 'Crawl finished with pending URLs — not auto-queuing (segmented crawls disabled)');
714
+ // visitedUrls includes all attempted URLs (screens, soft-404s, API paths) — more complete than
715
+ // discovered-with-screenId only, preventing the continuation job from re-attempting dead ends.
716
+ const visitedForSkip: string[] = (result as any).visitedUrls ?? ((result as any).discovered ?? [])
717
+ .filter((d: any) => !!d.screenId && !!d.url)
718
+ .map((d: any) => { try { const _u = new URL(d.url as string); _u.hash = ''; return _u.href; } catch { return d.url as string; } });
719
+ const reason = (result as any).wasTimeout ? 'timeout' : 'segment-limit';
720
+ logger.info({ pendingUrls: pendingUrls.length, visited: visitedForSkip.length, reason }, '[crawl] auto-enqueuing continuation job');
721
+ nextJobId = await enqueueJob('CRAWL', {
722
+ ...config,
723
+ startUrls: pendingUrls,
724
+ skipUrls: [...(config.skipUrls ?? []), ...visitedForSkip],
725
+ }).catch((autoErr: any) => { logger.warn({ err: autoErr?.message }, '[crawl] auto-segment enqueue failed (non-fatal)'); return undefined; });
696
726
  }
697
727
 
698
728
  await withTenant(tenantId, (tx) =>
@@ -700,7 +730,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
700
730
  where: { id: jobId },
701
731
  data: {
702
732
  status: 'DONE',
703
- result: { ...(result as any) } as any,
733
+ result: { ...(result as any), ...(nextJobId ? { nextJobId } : {}) } as any,
704
734
  progress: 100,
705
735
  finishedAt: new Date(),
706
736
  },
@@ -830,7 +860,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
830
860
  try {
831
861
  const key = buildArtifactKey(tenantId, projectId, 'dom-snapshots', `${screen.id}.json`, tenantStorageCfg.prefix);
832
862
  const cloudUrl = await uploadFileToTenantStorage(tenantStorageCfg, domPath, key, 'application/json');
833
- await AppBrain.updateScreenDomSnapshot(tenantId, screen.id, cloudUrl);
863
+ await AppBrain.updateScreenDomSnapshot(tenantId, screen.id, domPath, cloudUrl);
834
864
  domUploaded++;
835
865
  } catch (err) {
836
866
  logger.warn({ screenId: screen.id, err }, 'Failed to upload DOM snapshot to cloud storage');
@@ -898,8 +928,23 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
898
928
  clearInterval(heartbeatTimer);
899
929
  runningControllers.delete(jobId);
900
930
  const aborted = String(err).includes('CRAWL_ABORTED');
931
+ const authFailure = !aborted && (String(err).includes('AUTH_FAILURE') || String(err).includes('SESSION_EXPIRED'));
901
932
  const checkpoint = jobCheckpoints.get(jobId);
902
933
  jobCheckpoints.delete(jobId);
934
+
935
+ // Auth failed mid-crawl with work still pending — auto-requeue continuation job.
936
+ // The crawler checkpoints the remaining queue before throwing, so checkpoint.pendingQueue
937
+ // holds URLs not yet visited. skipUrls prevents re-crawling already-done pages.
938
+ if (authFailure && (checkpoint?.pendingQueue?.length ?? 0) > 0) {
939
+ const pendingAfterAuth: string[] = checkpoint!.pendingQueue!;
940
+ logger.info({ pending: pendingAfterAuth.length }, '[crawl] auth failure mid-crawl — auto-enqueuing continuation with pending URLs');
941
+ enqueueJob('CRAWL', {
942
+ ...config,
943
+ startUrls: pendingAfterAuth,
944
+ skipUrls: [...(config.skipUrls ?? []), ...(checkpoint!.visitedUrls ?? [])],
945
+ }).catch((enqErr: any) => logger.warn({ err: enqErr?.message }, '[crawl] auth-failure auto-segment enqueue failed (non-fatal)'));
946
+ }
947
+
903
948
  await withTenant(tenantId, (tx) =>
904
949
  tx.platformJob.update({
905
950
  where: { id: jobId },
@@ -2510,7 +2555,7 @@ jobDispatchTable = {
2510
2555
  BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
2511
2556
  CREATE_PR: processCreatePRJob,
2512
2557
  NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
2513
- // GENERATE_TESTS is processed in-process by the API (never QUEUED).
2558
+ // The following job types are processed in-process by the API (never QUEUED).
2514
2559
  // If the worker claims one, the API crashed after creating it — mark failed so it's visible.
2515
2560
  GENERATE_TESTS: async (jobId: string, _config: any) => {
2516
2561
  logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
@@ -2519,6 +2564,27 @@ jobDispatchTable = {
2519
2564
  data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2520
2565
  }).catch(() => {});
2521
2566
  },
2567
+ GENERATE_DATA: async (jobId: string, _config: any) => {
2568
+ logger.error({ jobId }, '[job-worker] GENERATE_DATA claimed by worker — API did not process it in-process');
2569
+ await prismaAdmin.platformJob.update({
2570
+ where: { id: jobId },
2571
+ data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2572
+ }).catch(() => {});
2573
+ },
2574
+ GENERATE_SCRIPT: async (jobId: string, _config: any) => {
2575
+ logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
2576
+ await prismaAdmin.platformJob.update({
2577
+ where: { id: jobId },
2578
+ data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2579
+ }).catch(() => {});
2580
+ },
2581
+ GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
2582
+ logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
2583
+ await prismaAdmin.platformJob.update({
2584
+ where: { id: jobId },
2585
+ data: { status: 'FAILED', error: 'Test plan generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2586
+ }).catch(() => {});
2587
+ },
2522
2588
  };
2523
2589
 
2524
2590
  async function processRepoScanJob(jobId: string, config: any) {
@@ -872,11 +872,12 @@ export async function runNativeCrawl(opts: NativeCrawlerOptions): Promise<void>
872
872
  const screen = await AppBrain.saveScreen(tenantId, projectId, screenName);
873
873
  const shotPath = path.join(screenshotDir, `${screen.id}-native-v${screen.version}.png`);
874
874
  await fs.writeFile(shotPath, screenshotBuf);
875
- await AppBrain.saveScreenshot(tenantId, projectId, screen.id, shotPath, screen.version, 'NATIVE');
875
+ let cloudUrl: string | undefined;
876
876
  if (tenantStorageCfg) {
877
877
  const s3Key = `${tenantId}/${projectId}/screenshots/${screen.id}-native-v${screen.version}.png`;
878
- await uploadScreenshotToTenantStorage(tenantStorageCfg, shotPath, s3Key).catch(() => {});
878
+ cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, shotPath, s3Key).catch(() => undefined);
879
879
  }
880
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, shotPath, screen.version, 'NATIVE', cloudUrl);
880
881
 
881
882
  // c. Parse accessibility tree → save elements
882
883
  // Scroll down to reveal below-fold elements, then scroll back up before parsing
@@ -1024,11 +1025,12 @@ export async function runNativeCrawl(opts: NativeCrawlerOptions): Promise<void>
1024
1025
  const newScreen = await AppBrain.saveScreen(tenantId, projectId, newScreenName);
1025
1026
  const newShotPath = path.join(screenshotDir, `${newScreen.id}-native-v${newScreen.version}.png`);
1026
1027
  await fs.writeFile(newShotPath, Buffer.from(newScreenshot, 'base64'));
1027
- await AppBrain.saveScreenshot(tenantId, projectId, newScreen.id, newShotPath, newScreen.version, 'NATIVE');
1028
+ let newCloudUrl: string | undefined;
1028
1029
  if (tenantStorageCfg) {
1029
1030
  const s3Key = `${tenantId}/${projectId}/screenshots/${newScreen.id}-native-v${newScreen.version}.png`;
1030
- await uploadScreenshotToTenantStorage(tenantStorageCfg, newShotPath, s3Key).catch(() => {});
1031
+ newCloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, newShotPath, s3Key).catch(() => undefined);
1031
1032
  }
1033
+ await AppBrain.saveScreenshot(tenantId, projectId, newScreen.id, newShotPath, newScreen.version, 'NATIVE', newCloudUrl);
1032
1034
 
1033
1035
  const newXml: string = await driver.getPageSource();
1034
1036
  const newElements = parseAccessibilityTree(newXml, platform);
@@ -164,6 +164,8 @@ export declare function crawlProject(opts: {
164
164
  segmentIndex: number;
165
165
  hasMoreSegments: boolean;
166
166
  domHashes: string[];
167
+ visitedUrls?: undefined;
168
+ wasTimeout?: undefined;
167
169
  crawlWarnings?: undefined;
168
170
  } | {
169
171
  screensFound: number;
@@ -186,6 +188,8 @@ export declare function crawlProject(opts: {
186
188
  apiEndpoints: ApiEndpointObservationInput[];
187
189
  performanceMetrics: PerformanceMetric[];
188
190
  pendingUrls: string[];
191
+ visitedUrls: string[];
192
+ wasTimeout: boolean;
189
193
  segmentIndex: number;
190
194
  hasMoreSegments: boolean;
191
195
  domHashes: string[];
@@ -82,7 +82,9 @@ export declare function claimSpecificJob(jobId: string): Promise<ClaimedJob | nu
82
82
  export declare function claimNextQueuedJob(): Promise<ClaimedJob | null>;
83
83
  /** Requeues RUNNING jobs whose heartbeat has gone stale — the worker that claimed them
84
84
  * died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and
85
- * safe to run from every worker process; only affects rows past the timeout. */
85
+ * safe to run from every worker process; only affects rows past the timeout.
86
+ * API-in-process job types (GENERATE_SCRIPT etc.) are failed directly — requeueing them
87
+ * would cause the worker to claim and fail them with a confusing "claimed by worker" error. */
86
88
  export declare function requeueStaleRunningJobs(): Promise<void>;
87
89
  /**
88
90
  * Requeues any RUNNING jobs claimed by a different worker (i.e., a previous