@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/dist/auth.js +12 -2
- package/dist/auth.js.map +2 -2
- package/dist/crawler.js +370 -155
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +71 -4
- package/dist/job-queue.js.map +2 -2
- package/dist/native-crawler.js +6 -6
- package/dist/native-crawler.js.map +2 -2
- package/package.json +1 -1
- package/src/auth.ts +13 -1
- package/src/crawler.ts +307 -60
- package/src/job-queue.ts +71 -5
- package/src/native-crawler.ts +6 -4
- package/types/crawler.d.ts +4 -0
- package/types/job-queue.d.ts +3 -1
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
|
-
|
|
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
|
-
//
|
|
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) {
|
package/src/native-crawler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/types/crawler.d.ts
CHANGED
|
@@ -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[];
|
package/types/job-queue.d.ts
CHANGED
|
@@ -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
|