app-manager-edge-worker 1.2.4 → 1.3.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.
package/README.md CHANGED
@@ -201,7 +201,18 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
201
201
 
202
202
  ---
203
203
 
204
- ## 📝 Changelog
204
+ ### v1.3.0
205
+ - **Multi-Error Identifier Rules & Inspection Resilience Engine**:
206
+ - **Custom Error Identifier Rules**: Supported typed error detection rules (`errorDetection.rules`) allowing classification of multiple distinct errors (e.g. `INVALID_GSTIN`, `INVALID_CAPTCHA`, `NOT_FOUND`) with tailored actions (`RETRY_CAPTCHA`, `BUSINESS_ERROR`, `ABORT_SESSION`).
207
+ - **Inspection Mode Zero-Purge Resilience**: When `preservePageForInspection: true`, the worker never terminates the session on errors or timeouts. Instead, it settles the DOM, takes a full-page snapshot, and returns the live updated screenshot to the visual element inspector so users can inspect red error messages and map error selectors.
208
+ - **Broadened Intelligent Error Heuristics**: Added automatic recognition for standard Indian government and SPA error selectors (`p#alertMsg`, `#alertMsg`, `.alertMsg`, `.text-danger`, `.invalid-feedback`, `[aria-invalid='true']`), resolving outcome races in sub-second time without 15s timeouts.
209
+ - **Added `HITL_SESSION_SNAPSHOT`**: On-demand snapshot handler allowing the visual inspector to capture and synchronize current browser page state at any point.
210
+
211
+ ### v1.2.5
212
+ - **Interactive Session Visual Inspector Sync & Robust Captcha Refresh**:
213
+ - `HITL_SESSION_INIT` now captures and returns full-page `pageScreenshotBase64`, dimensions, and title after typing initial form inputs, keeping the Visual Element Inspector synchronized with the active session.
214
+ - Enhanced `HITL_SESSION_REFRESH` with multi-selector fallback candidates (`button[ng-click*="refreshCaptcha"]`, `button[ng-click*="captcha"]`, `#reloadCaptcha`, etc.) ensuring reliable refresh on AngularJS and custom portal architectures.
215
+ - Captures and returns updated `pageScreenshotBase64` upon captcha refresh to keep inspect canvas up to date.
205
216
 
206
217
  ### v1.2.4
207
218
  - **Preserved Live Inspection & Field Extraction on Results Pages**:
@@ -252,6 +263,13 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
252
263
  - Automatic backup of pre-existing local disk sessions to cluster database on initial connection.
253
264
  - Coordinated session wiping on remote logout and phone unlinking.
254
265
 
266
+ ### v1.2.5
267
+ - **Interactive Human-In-The-Loop (HITL) Web Scraping**:
268
+ - Pinned stateful browser sessions for interactive portals (GSTIN, PAN, Parivahan, e-Courts).
269
+ - Multi-step execution protocol: `HITL_SESSION_INIT`, `HITL_SESSION_REFRESH`, `HITL_SESSION_SOLVE`, `HITL_SESSION_EXTRACT`, and `HITL_SESSION_ABORT`.
270
+ - Memory-safe binary captcha streaming and bounded 90s session TTL auto-termination.
271
+ - Server URL resolution propagation for Baileys companion document relay.
272
+
255
273
  ### v1.1.0
256
274
  - **WhatsApp Multi-Device Companion Relay**:
257
275
  - Independent companion device pairing via live QR streaming to Admin Dashboard.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-manager-edge-worker",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "Distributed Scraper Edge Worker for Apps Manager. Run on any local machine, VPS, or Termux Android device to serve browser and HTTP scraping jobs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/banner.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "module";
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
- let workerVersion = "1.2.4";
4
+ let workerVersion = "1.3.0";
5
5
  try {
6
6
  const pkg = require("../package.json");
7
7
  if (pkg?.version) workerVersion = pkg.version;
package/src/client.js CHANGED
@@ -14,12 +14,13 @@ import {
14
14
  handleInteractiveSessionSolve,
15
15
  handleInteractiveSessionAbort,
16
16
  handleInteractiveSessionExtract,
17
+ handleInteractiveSessionSnapshot,
17
18
  } from "./handlers/interactiveHandler.js";
18
19
  import fs from "fs";
19
20
  import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
20
21
  import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
21
22
 
22
- let workerPkgVersion = "1.2.4";
23
+ let workerPkgVersion = "1.3.0";
23
24
  try {
24
25
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
25
26
  if (pkg?.version) workerPkgVersion = pkg.version;
@@ -263,6 +264,8 @@ export async function startWorkerClient(config) {
263
264
  resultData = await handleInteractiveSessionRefresh(payload);
264
265
  } else if (jobType === "HITL_SESSION_SOLVE") {
265
266
  resultData = await handleInteractiveSessionSolve(payload);
267
+ } else if (jobType === "HITL_SESSION_SNAPSHOT") {
268
+ resultData = await handleInteractiveSessionSnapshot(payload);
266
269
  } else if (jobType === "HITL_SESSION_EXTRACT") {
267
270
  resultData = await handleInteractiveSessionExtract(payload);
268
271
  } else if (jobType === "HITL_SESSION_ABORT") {
@@ -223,6 +223,25 @@ export async function handleInteractiveSessionInit(payload) {
223
223
  });
224
224
  const imageBase64 = Buffer.from(screenshotBuffer).toString("base64");
225
225
 
226
+ // Settle briefly for form state & portal validation UI
227
+ await new Promise((r) => setTimeout(r, 400));
228
+
229
+ // Capture full-page screenshot showing the filled inputs and revealed captcha
230
+ const pageScreenshotBuffer = await page.screenshot({
231
+ encoding: "binary",
232
+ fullPage: false,
233
+ type: "jpeg",
234
+ quality: 85,
235
+ });
236
+ const pageScreenshotBase64 = `data:image/jpeg;base64,${Buffer.from(pageScreenshotBuffer).toString("base64")}`;
237
+ const title = (await page.title().catch(() => "")) || "Interactive Portal Session";
238
+ const dimensions = await page
239
+ .evaluate(() => ({
240
+ width: document.documentElement.scrollWidth || window.innerWidth,
241
+ height: document.documentElement.scrollHeight || window.innerHeight,
242
+ }))
243
+ .catch(() => ({ width: 1366, height: 900 }));
244
+
226
245
  const sessionTtlSeconds = captchaConfig.sessionTimeoutSeconds || 90;
227
246
 
228
247
  // Set auto-purge timer to prevent resource leaks
@@ -244,6 +263,9 @@ export async function handleInteractiveSessionInit(payload) {
244
263
  return {
245
264
  sessionId,
246
265
  imageBase64: `data:image/png;base64,${imageBase64}`,
266
+ pageScreenshotBase64,
267
+ dimensions,
268
+ title,
247
269
  expiresInSeconds: sessionTtlSeconds,
248
270
  };
249
271
  } catch (err) {
@@ -269,10 +291,6 @@ export async function handleInteractiveSessionRefresh(payload) {
269
291
  const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
270
292
  const refreshSelector = cfg.refreshSelector;
271
293
 
272
- if (!refreshSelector) {
273
- throw new Error("refreshSelector is not configured for this captcha");
274
- }
275
-
276
294
  // 1. Snapshot current image hash and src attribute
277
295
  const oldEl = await page.$(imageSelector);
278
296
  if (!oldEl) {
@@ -287,9 +305,41 @@ export async function handleInteractiveSessionRefresh(payload) {
287
305
  return el ? el.getAttribute("src") : null;
288
306
  }, imageSelector);
289
307
 
290
- // 2. Click the reload button
291
- await page.waitForSelector(refreshSelector, { timeout: 3000 });
292
- await page.click(refreshSelector);
308
+ // 2. Click the reload button with robust candidate fallbacks
309
+ const candidateSelectors = [
310
+ refreshSelector,
311
+ "button[ng-click*='refreshCaptcha' i]",
312
+ "button[ng-click*='captcha' i]",
313
+ "*[ng-click*='refreshCaptcha' i]",
314
+ "*[ng-click*='captcha' i]",
315
+ "#reloadCaptcha",
316
+ "#refreshCaptcha",
317
+ "a[title*='refresh' i]",
318
+ "button[title*='refresh' i]",
319
+ "a[title*='reload' i]",
320
+ "button[title*='reload' i]",
321
+ ".captcha-refresh",
322
+ ".reload-captcha",
323
+ ".glyphicon-refresh",
324
+ "i.fa-refresh",
325
+ "i.fa-arrows-rotate",
326
+ ].filter(Boolean);
327
+
328
+ let clicked = false;
329
+ for (const sel of candidateSelectors) {
330
+ try {
331
+ const btn = await page.$(sel);
332
+ if (btn) {
333
+ await btn.click();
334
+ clicked = true;
335
+ break;
336
+ }
337
+ } catch (_) {}
338
+ }
339
+
340
+ if (!clicked) {
341
+ throw new Error(`Captcha refresh button not found on page (tried: ${refreshSelector || "standard reload selectors"})`);
342
+ }
293
343
 
294
344
  // 3. Wait for src attribute change first
295
345
  await page
@@ -326,9 +376,20 @@ export async function handleInteractiveSessionRefresh(payload) {
326
376
  }
327
377
 
328
378
  const imageBase64 = Buffer.from(newBuffer).toString("base64");
379
+
380
+ // Capture updated full page screenshot showing new captcha
381
+ const pageScreenshotBuffer = await page.screenshot({
382
+ encoding: "binary",
383
+ fullPage: false,
384
+ type: "jpeg",
385
+ quality: 85,
386
+ });
387
+ const pageScreenshotBase64 = `data:image/jpeg;base64,${Buffer.from(pageScreenshotBuffer).toString("base64")}`;
388
+
329
389
  return {
330
390
  sessionId,
331
391
  imageBase64: `data:image/png;base64,${imageBase64}`,
392
+ pageScreenshotBase64,
332
393
  };
333
394
  }
334
395
 
@@ -361,7 +422,24 @@ export async function handleInteractiveSessionSolve(payload) {
361
422
  const inputSelector = cfg.inputSelector || "#captcha, input[name='captcha']";
362
423
  const submitSelector = cfg.submitSelector || "button[type='submit']";
363
424
  const successSelector = cfg.waitForAfterSubmit?.selector || ".table-responsive, table, .result-pane";
364
- const errorSelector = cfg.errorDetection?.selector || ".alert-danger, .has-error, #error-msg, .ui-messages-error";
425
+
426
+ // Broad default error selectors covering Indian gov and modern SPA patterns
427
+ const defaultBroadErrorSelector =
428
+ "p#alertMsg, #alertMsg, .alertMsg, .alert-danger, .alert-warning, " +
429
+ ".has-error, .text-danger, #error-msg, .error-message, .err-msg, " +
430
+ ".ui-messages-error, [class*='error-msg'], [class*='alert-message'], " +
431
+ ".invalid-feedback, .help-block.error, .form-error, [aria-invalid='true']";
432
+
433
+ const customRules = cfg.errorDetection?.rules || [];
434
+ const ruleSelectors = customRules.map((r) => r.selector).filter(Boolean);
435
+ const combinedErrorSelectors = [
436
+ cfg.errorDetection?.selector,
437
+ ...ruleSelectors,
438
+ defaultBroadErrorSelector,
439
+ ]
440
+ .filter(Boolean)
441
+ .join(", ");
442
+
365
443
  const invalidPattern = cfg.errorDetection?.invalidCaptchaPattern || "captcha.*(incorrect|invalid|wrong|mismatch|enter text)";
366
444
 
367
445
  // 1. Type captcha solution
@@ -375,17 +453,32 @@ export async function handleInteractiveSessionSolve(payload) {
375
453
  await page.click(submitSelector);
376
454
 
377
455
  // 3. Post-Submit Race: Wait for Success Selector OR Error Selector
456
+ // In inspection mode, use a shorter safety window (5-7s) so users aren't stalled waiting on empty selectors
457
+ const safetyTimeoutMs = preservePageForInspection
458
+ ? Math.min(cfg.waitForAfterSubmit?.timeoutMs || 8000, 6000)
459
+ : (cfg.waitForAfterSubmit?.timeoutMs || 15000);
460
+
378
461
  const matchedOutcome = await page
379
462
  .waitForFunction(
380
463
  (succ, err) => {
381
464
  if (document.querySelector(succ)) return "SUCCESS";
382
- const errEl = document.querySelector(err);
383
- if (errEl && errEl.innerText && errEl.innerText.trim().length > 0) return "ERROR";
465
+ try {
466
+ const elements = document.querySelectorAll(err);
467
+ for (const el of elements) {
468
+ const txt = (el.innerText || el.textContent || "").trim();
469
+ if (txt.length > 0) {
470
+ const style = window.getComputedStyle(el);
471
+ if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
472
+ return "ERROR";
473
+ }
474
+ }
475
+ }
476
+ } catch (e) {}
384
477
  return null;
385
478
  },
386
- { timeout: cfg.waitForAfterSubmit?.timeoutMs || 15000 },
479
+ { timeout: safetyTimeoutMs },
387
480
  successSelector,
388
- errorSelector
481
+ combinedErrorSelectors
389
482
  )
390
483
  .then((handle) => handle.jsonValue())
391
484
  .catch(() => "TIMEOUT");
@@ -421,28 +514,12 @@ export async function handleInteractiveSessionSolve(payload) {
421
514
  // Settle time for tables & styles
422
515
  await new Promise((r) => setTimeout(r, 600));
423
516
 
424
- const screenshotBuffer = await page.screenshot({
425
- encoding: "binary",
426
- fullPage: false,
427
- type: "jpeg",
428
- quality: 85,
429
- });
430
- const screenshotBase64 = Buffer.from(screenshotBuffer).toString("base64");
431
- const title = await page.title().catch(() => "");
432
- const dimensions = await page
433
- .evaluate(() => ({
434
- width: document.documentElement.scrollWidth || window.innerWidth,
435
- height: document.documentElement.scrollHeight || window.innerHeight,
436
- }))
437
- .catch(() => ({ width: 1366, height: 900 }));
438
-
517
+ const snap = await captureInspectionSnapshot(page);
439
518
  return {
440
519
  status: "COMPLETED",
441
520
  success: true,
442
521
  data: raw,
443
- screenshotBase64: `data:image/jpeg;base64,${screenshotBase64}`,
444
- dimensions,
445
- title: title || "Results Page",
522
+ ...snap,
446
523
  sessionId,
447
524
  isInspectionMode: true,
448
525
  };
@@ -458,55 +535,129 @@ export async function handleInteractiveSessionSolve(payload) {
458
535
  };
459
536
  }
460
537
 
461
- // Case B: In-Page Error Banner Detected
462
- if (matchedOutcome === "ERROR") {
463
- const errorText = await page.evaluate((sel) => {
464
- const el = document.querySelector(sel);
465
- return el ? el.innerText.trim() : "";
466
- }, errorSelector);
538
+ // Evaluate error rules and messages if matchedOutcome === "ERROR" or if we are inspecting
539
+ let matchedRule = null;
540
+ let errorText = "";
541
+
542
+ // 1. Check custom configured rules first
543
+ for (const rule of customRules) {
544
+ if (!rule.selector) continue;
545
+ const check = await page.evaluate((sel, pat) => {
546
+ try {
547
+ const els = document.querySelectorAll(sel);
548
+ for (const el of els) {
549
+ const txt = (el.innerText || el.textContent || "").trim();
550
+ if (txt) {
551
+ const style = window.getComputedStyle(el);
552
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
553
+ if (!pat) return { matched: true, text: txt };
554
+ const re = new RegExp(pat, "i");
555
+ if (re.test(txt)) return { matched: true, text: txt };
556
+ }
557
+ }
558
+ } catch (e) {}
559
+ return null;
560
+ }, rule.selector, rule.pattern);
561
+
562
+ if (check && check.matched) {
563
+ matchedRule = rule;
564
+ errorText = check.text;
565
+ break;
566
+ }
567
+ }
467
568
 
468
- const regex = new RegExp(invalidPattern, "i");
469
- const isCaptchaError = regex.test(errorText);
569
+ // 2. If no custom rule matched, extract text from combined error selectors
570
+ if (!matchedRule) {
571
+ errorText = await page.evaluate((sel) => {
572
+ try {
573
+ const els = document.querySelectorAll(sel);
574
+ for (const el of els) {
575
+ const txt = (el.innerText || el.textContent || "").trim();
576
+ if (txt) {
577
+ const style = window.getComputedStyle(el);
578
+ if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
579
+ return txt;
580
+ }
581
+ }
582
+ }
583
+ } catch (e) {}
584
+ return "";
585
+ }, combinedErrorSelectors);
586
+ }
470
587
 
471
- if (isCaptchaError) {
472
- // Keep page alive! Reset TTL timer for retry
473
- if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
474
- const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
475
- session.timeoutTimer = setTimeout(() => {
476
- purgeSession(sessionId).catch(() => {});
477
- }, retryTtlSeconds * 1000);
588
+ const regex = new RegExp(invalidPattern, "i");
589
+ const isCaptchaError = matchedRule
590
+ ? matchedRule.action === "RETRY_CAPTCHA"
591
+ : (Boolean(errorText) && regex.test(errorText));
478
592
 
479
- // Give the target portal 400ms to re-render the refreshed captcha image
480
- await new Promise((r) => setTimeout(r, 400));
481
- const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
482
- const newCaptchaEl = await page.$(imageSelector);
483
- let newImageBase64 = null;
593
+ // Check if new captcha image was rendered on page
594
+ const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
595
+ let newImageBase64 = null;
596
+ try {
597
+ const newCaptchaEl = await page.$(imageSelector);
598
+ if (newCaptchaEl) {
599
+ const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
600
+ newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
601
+ }
602
+ } catch (e) {}
603
+
604
+ // CRITICAL FOR INSPECTOR: In inspection mode, NEVER purge the session on errors or timeout!
605
+ // Always snap the live DOM and return it so the user can inspect the red text and configure selectors
606
+ if (preservePageForInspection) {
607
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
608
+ const inspectTtl = 300;
609
+ session.timeoutTimer = setTimeout(() => {
610
+ purgeSession(sessionId).catch(() => {});
611
+ }, inspectTtl * 1000);
484
612
 
485
- if (newCaptchaEl) {
486
- const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
487
- newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
488
- }
613
+ const snap = await captureInspectionSnapshot(page);
614
+ const effectiveStatus = isCaptchaError
615
+ ? "INVALID_CAPTCHA"
616
+ : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE");
489
617
 
490
- return {
491
- status: "INVALID_CAPTCHA",
492
- success: false,
493
- message: errorText || "The captcha entered was incorrect.",
494
- canRetry: true,
495
- newImageBase64,
496
- };
497
- } else {
498
- // Business error (e.g. "No record found for this number")
499
- await purgeSession(sessionId);
500
- return {
501
- status: "BUSINESS_ERROR",
502
- success: false,
503
- message: errorText || "Target portal returned an error.",
504
- canRetry: false,
505
- };
506
- }
618
+ return {
619
+ status: effectiveStatus,
620
+ success: true, // Allows frontend to render the inspection screenshot
621
+ errorCode: (matchedRule && matchedRule.id) || (isCaptchaError ? "INVALID_CAPTCHA" : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE")),
622
+ message: (matchedRule && matchedRule.customMessage) || errorText || (matchedOutcome === "TIMEOUT" ? "Page updated (no success table found). You can now inspect elements or error messages." : "Page outcome revealed for inspection."),
623
+ newImageBase64,
624
+ matchedRule: matchedRule ? { id: matchedRule.id, name: matchedRule.name, action: matchedRule.action } : null,
625
+ sessionId,
626
+ isInspectionMode: true,
627
+ ...snap,
628
+ };
629
+ }
630
+
631
+ // Runtime non-inspection mode:
632
+ if (isCaptchaError) {
633
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
634
+ const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
635
+ session.timeoutTimer = setTimeout(() => {
636
+ purgeSession(sessionId).catch(() => {});
637
+ }, retryTtlSeconds * 1000);
638
+
639
+ return {
640
+ status: "INVALID_CAPTCHA",
641
+ success: false,
642
+ message: (matchedRule && matchedRule.customMessage) || errorText || "The captcha entered was incorrect.",
643
+ canRetry: true,
644
+ newImageBase64,
645
+ errorCode: (matchedRule && matchedRule.id) || "INVALID_CAPTCHA",
646
+ };
507
647
  }
508
648
 
509
- // Case C: Timeout
649
+ if (errorText || matchedRule) {
650
+ await purgeSession(sessionId);
651
+ return {
652
+ status: "BUSINESS_ERROR",
653
+ success: false,
654
+ errorCode: (matchedRule && matchedRule.id) || "BUSINESS_ERROR",
655
+ message: (matchedRule && matchedRule.customMessage) || errorText || "Target portal returned an error.",
656
+ canRetry: false,
657
+ };
658
+ }
659
+
660
+ // Runtime Timeout
510
661
  await purgeSession(sessionId);
511
662
  return {
512
663
  status: "TIMEOUT",
@@ -516,6 +667,58 @@ export async function handleInteractiveSessionSolve(payload) {
516
667
  };
517
668
  }
518
669
 
670
+ /**
671
+ * Capture full inspection snapshot helper
672
+ */
673
+ async function captureInspectionSnapshot(page) {
674
+ await new Promise((r) => setTimeout(r, 500));
675
+ const screenshotBuffer = await page.screenshot({
676
+ encoding: "binary",
677
+ fullPage: false,
678
+ type: "jpeg",
679
+ quality: 85,
680
+ });
681
+ const screenshotBase64 = `data:image/jpeg;base64,${Buffer.from(screenshotBuffer).toString("base64")}`;
682
+ const title = await page.title().catch(() => "");
683
+ const dimensions = await page
684
+ .evaluate(() => ({
685
+ width: document.documentElement.scrollWidth || window.innerWidth,
686
+ height: document.documentElement.scrollHeight || window.innerHeight,
687
+ }))
688
+ .catch(() => ({ width: 1366, height: 900 }));
689
+
690
+ return {
691
+ screenshotBase64,
692
+ dimensions,
693
+ title: title || "Inspection Page",
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Force capture snapshot of the active interactive session's page
699
+ */
700
+ export async function handleInteractiveSessionSnapshot(payload) {
701
+ const { sessionId } = payload;
702
+ const session = activeSessions.get(sessionId);
703
+
704
+ if (!session || !session.page || session.page.isClosed()) {
705
+ throw new Error("Interactive session not found or already expired");
706
+ }
707
+
708
+ const { page } = session;
709
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
710
+ session.timeoutTimer = setTimeout(() => {
711
+ purgeSession(sessionId).catch(() => {});
712
+ }, 300 * 1000);
713
+
714
+ const snap = await captureInspectionSnapshot(page);
715
+ return {
716
+ success: true,
717
+ sessionId,
718
+ ...snap,
719
+ };
720
+ }
721
+
519
722
  /**
520
723
  * 4. Handles HITL_SESSION_ABORT:
521
724
  * Explicit client abort or session abandonment.