@lincoln504/pi-research 0.1.12 → 1.0.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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Thread Worker Messaging Logic
3
+ *
4
+ * Handles message processing and task execution for the thread worker.
5
+ */
6
+
7
+ let workerId: string = '';
8
+
9
+ import { appendFileSync } from 'node:fs';
10
+ import { redactSecrets } from '../../utils/log-utils.ts';
11
+
12
+ /**
13
+ * Mutex lock to serialize page creation. Playwright/Firefox can deadlock if newPage()
14
+ * is called concurrently on the exact same context instance.
15
+ */
16
+ let pageCreationLock = Promise.resolve();
17
+
18
+ async function createPageSafe(context: any): Promise<any> {
19
+ let release!: () => void;
20
+ const nextLock = new Promise<void>(resolve => { release = resolve; });
21
+ const currentLock = pageCreationLock;
22
+ pageCreationLock = currentLock.then(() => nextLock);
23
+
24
+ await currentLock;
25
+ try {
26
+ const pagePromise = context.newPage();
27
+ pagePromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background page creation rejection: ${err.message}`));
28
+ let timeoutId: NodeJS.Timeout | undefined;
29
+ try {
30
+ const result = await Promise.race([
31
+ pagePromise,
32
+ new Promise<never>((_, reject) => {
33
+ timeoutId = setTimeout(() => reject(new Error('Browser page creation timed out after 60000ms')), 60000);
34
+ })
35
+ ]);
36
+ if (timeoutId) clearTimeout(timeoutId);
37
+ return result;
38
+ } catch (err) {
39
+ if (timeoutId) clearTimeout(timeoutId);
40
+ // The timeout (or another error) won the race. If newPage() later resolves,
41
+ // close that orphaned page so it doesn't accumulate in the long-lived context.
42
+ pagePromise.then((p: any) => { try { p?.close?.(); } catch { /* already gone */ } }).catch(() => {});
43
+ throw err;
44
+ }
45
+ } finally {
46
+ release();
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Set the worker ID for logging purposes
52
+ */
53
+ export function setWorkerId(id: string): void {
54
+ workerId = id;
55
+ }
56
+
57
+ /**
58
+ * Log to debug file
59
+ */
60
+ function logToDebugFile(level: string, ...args: any[]): void {
61
+ const logFile = process.env['PI_RESEARCH_LOG_FILE'];
62
+ if (!logFile) return;
63
+ // Match the main-process logger: WARN/ERROR are always recorded (crash diagnostics);
64
+ // INFO/DEBUG only when verbose. Without this gate a non-verbose scaling run still
65
+ // streamed thousands of worker DEBUG lines to PI_RESEARCH_LOG_FILE — often a tmpfs
66
+ // (RAM) path — adding memory and I/O pressure for no benefit during sustained loops.
67
+ if ((level === 'INFO' || level === 'DEBUG') && process.env['PI_RESEARCH_DEBUG'] !== 'true') return;
68
+
69
+ try {
70
+ const timestamp = new Date().toISOString();
71
+ const entry = {
72
+ timestamp,
73
+ level,
74
+ workerId,
75
+ // Redact secrets + bound size; worker logs include full scrape/search
76
+ // URLs, which can carry credentials in userinfo or query strings.
77
+ message: redactSecrets(args.map(arg => {
78
+ if (arg instanceof Error) return arg.stack || arg.message;
79
+ if (typeof arg === 'object' && arg !== null) return JSON.stringify(arg);
80
+ return String(arg);
81
+ }).join(' '))
82
+ };
83
+ appendFileSync(logFile, `${JSON.stringify(entry)}\n`);
84
+ } catch {
85
+ // ignore
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Setup mocking for the browser context if configured via environment variables.
91
+ * This is used in CI to make integration tests fast and reliable without hitting real networks.
92
+ */
93
+ export async function setupMocking(context: any): Promise<void> {
94
+ const mockSearch = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true';
95
+ const mockScrape = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
96
+
97
+ if (!mockSearch && !mockScrape) return;
98
+
99
+ logToDebugFile('INFO', `[Worker-${workerId}] Setting up browser mocking (Search: ${mockSearch}, Scrape: ${mockScrape})`);
100
+
101
+ await context.route('**', (route: any, request: any) => {
102
+ const urlStr = request.url();
103
+ let url: URL;
104
+ try {
105
+ url = new URL(urlStr);
106
+ } catch {
107
+ route.continue();
108
+ return;
109
+ }
110
+
111
+ const isDuckDuckGo = url.hostname === 'duckduckgo.com' ||
112
+ url.hostname === 'lite.duckduckgo.com' ||
113
+ url.hostname.endsWith('.duckduckgo.com');
114
+
115
+ if (mockSearch && isDuckDuckGo) {
116
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Mocking search response for: ${urlStr}`);
117
+ route.fulfill({
118
+ status: 200,
119
+ contentType: 'text/html',
120
+ body: `
121
+ <html><head><title>DuckDuckGo Lite</title></head><body>
122
+ <form action="/lite/" method="post">
123
+ <input name="q" type="text">
124
+ <input type="submit" value="Search">
125
+ </form>
126
+ <table class="result-link">
127
+ <tr>
128
+ <td><a class="result-link" href="https://example.com/mock-result-1?uddg=https%3A%2F%2Fexample.com%2Ftarget-1">Mock Result 1</a></td>
129
+ </tr>
130
+ <tr>
131
+ <td class="result-snippet">This is a mocked search result snippet for testing purposes.</td>
132
+ </tr>
133
+ <tr>
134
+ <td><a class="result-link" href="https://example.com/mock-result-2?uddg=https%3A%2F%2Fexample.com%2Ftarget-2">Mock Result 2</a></td>
135
+ </tr>
136
+ <tr>
137
+ <td class="result-snippet">Another mocked snippet to verify multiple result extraction.</td>
138
+ </tr>
139
+ </table>
140
+ </body></html>
141
+ `
142
+ });
143
+ return;
144
+ }
145
+
146
+ if (mockScrape && !isDuckDuckGo) {
147
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Mocking scrape response for: ${urlStr}`);
148
+ route.fulfill({
149
+ status: 200,
150
+ contentType: 'text/html',
151
+ body: `<html><body><h1>Mocked Page Content</h1><p>This content was mocked for ${url} during integration testing.</p></body></html>`
152
+ });
153
+ return;
154
+ }
155
+
156
+ route.continue();
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Extract search results from DuckDuckGo Lite page
162
+ */
163
+ async function extractSearchResults(page: any): Promise<any[]> {
164
+ return await page.evaluate(() => {
165
+ const found: any[] = [];
166
+ // @ts-ignore - document is available in browser context
167
+ // eslint-disable-next-line no-undef
168
+ const links = Array.from(document.querySelectorAll('a.result-link'));
169
+ links.forEach((link: any) => {
170
+ const row = link.closest('tr');
171
+ const snippet = row?.nextElementSibling?.querySelector('td.result-snippet')?.textContent?.trim() || '';
172
+ const title = link.textContent?.trim() || '';
173
+ let url = link.href;
174
+ try {
175
+ const u = new URL(url);
176
+ const uddg = u.searchParams.get('uddg');
177
+ if (uddg) url = decodeURIComponent(uddg || '');
178
+ } catch {
179
+ // ignore
180
+ }
181
+ let urlHostname = '';
182
+ try { urlHostname = new URL(url).hostname; } catch { /* ignore malformed */ }
183
+ if (title && url && urlHostname !== 'duckduckgo.com' && !urlHostname.endsWith('.duckduckgo.com') && url.startsWith('http')) {
184
+ found.push({ title, url, content: snippet });
185
+ }
186
+ });
187
+ return found;
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Execute a search task
193
+ */
194
+ export async function executeSearchTask(
195
+ _context: any,
196
+ query: string
197
+ ): Promise<{ results: any[]; jitter: number }> {
198
+ const page = await createPageSafe(_context);
199
+ const SEARCH_TIMEOUT = parseInt(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'] || '45000', 10);
200
+ page.setDefaultTimeout(SEARCH_TIMEOUT);
201
+ page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
202
+
203
+ try {
204
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Starting search for: ${query}`);
205
+
206
+ // In mock mode, we jump directly to the results URL to avoid slow form-fill and
207
+ // interactability checks which can be brittle in CI environments.
208
+ if (process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true') {
209
+ await page.goto(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`, { waitUntil: 'domcontentloaded' });
210
+ } else {
211
+ // Relaxed timeout to accommodate network latency and concurrent worker load
212
+ await page.goto('https://lite.duckduckgo.com/lite/', { waitUntil: 'domcontentloaded' });
213
+ await page.fill('input[name="q"]', query);
214
+ await Promise.all([
215
+ page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
216
+ page.keyboard.press('Enter')
217
+ ]);
218
+ }
219
+
220
+ const results = await extractSearchResults(page);
221
+
222
+ await page.close();
223
+
224
+ // Reduce jitter in CI/mock mode to speed up tests
225
+ const jitterBase = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' ? 10 : 500;
226
+ const jitterRange = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' ? 10 : 1000;
227
+ const jitter = Math.floor(Math.random() * jitterRange) + jitterBase;
228
+ await new Promise(r => setTimeout(r, jitter));
229
+
230
+ return { results, jitter };
231
+ } catch (error) {
232
+ await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Short-TTL, per-worker cache of per-hostname DNS-aware SSRF verdicts for
239
+ * SUBRESOURCE requests. Subresources previously got only the synchronous
240
+ * literal/pattern screen (no DNS), so a hostile page — whose own navigation
241
+ * DNS-validated as public — could fetch() a hostname that resolves to a
242
+ * private/link-local/metadata address (TTL-0 DNS rebinding) and read the
243
+ * response into the DOM that page.content() returns. Every http(s) subresource
244
+ * hostname now gets the same DNS-aware validateUrlForSSRF as navigations; this
245
+ * cache keeps that to ONE resolution per hostname per TTL (the sync screen's
246
+ * whole purpose was avoiding per-asset DNS stalls — a page with 40 assets on
247
+ * one CDN host costs one lookup, not 40, and same-origin assets are seeded by
248
+ * the navigation's own validation). The TTL is deliberately short so a verdict
249
+ * cannot stay stale for long, and this pre-validation is only the first layer:
250
+ * DNS pre-checks are inherently TOCTOU-racy against a rebinding resolver
251
+ * (Firefox resolves independently at connect time; camoufox has no connect-time
252
+ * IP pinning), so the response-side serverAddr() backstop in executeScrapeTask
253
+ * is the authoritative rebinding defense.
254
+ */
255
+ const SUBRESOURCE_DNS_VERDICT_TTL_MS = 15_000;
256
+ const SUBRESOURCE_DNS_VERDICT_MAX_ENTRIES = 256;
257
+ const subresourceDnsVerdicts = new Map<string, { expires: number; verdict: Promise<Error | null> }>();
258
+
259
+ function getCachedSubresourceVerdict(
260
+ reqUrl: string,
261
+ validate: (u: string) => Promise<void>,
262
+ ): Promise<Error | null> {
263
+ let hostname: string;
264
+ try {
265
+ hostname = new URL(reqUrl).hostname.toLowerCase();
266
+ } catch (e) {
267
+ return Promise.resolve(e instanceof Error ? e : new Error(String(e)));
268
+ }
269
+ const now = Date.now();
270
+ const cached = subresourceDnsVerdicts.get(hostname);
271
+ if (cached && cached.expires > now) return cached.verdict;
272
+ // Store the SETTLED outcome (null = allowed, Error = blocked) instead of a
273
+ // rejecting promise so a cached denial can never surface as an unhandled
274
+ // rejection, and concurrent requests for the same hostname share one lookup.
275
+ const verdict = validate(reqUrl).then(
276
+ () => null,
277
+ (e: unknown) => (e instanceof Error ? e : new Error(String(e))),
278
+ );
279
+ if (subresourceDnsVerdicts.size >= SUBRESOURCE_DNS_VERDICT_MAX_ENTRIES) {
280
+ for (const [key, entry] of subresourceDnsVerdicts) {
281
+ if (entry.expires <= now) subresourceDnsVerdicts.delete(key);
282
+ }
283
+ // Still full after dropping expired entries: evict oldest-inserted.
284
+ while (subresourceDnsVerdicts.size >= SUBRESOURCE_DNS_VERDICT_MAX_ENTRIES) {
285
+ const oldest = subresourceDnsVerdicts.keys().next().value;
286
+ if (oldest === undefined) break;
287
+ subresourceDnsVerdicts.delete(oldest);
288
+ }
289
+ }
290
+ subresourceDnsVerdicts.set(hostname, { expires: now + SUBRESOURCE_DNS_VERDICT_TTL_MS, verdict });
291
+ return verdict;
292
+ }
293
+
294
+ /** Seed the subresource verdict cache with a PASS for a hostname whose full DNS-aware validation just succeeded (the navigation), so same-origin assets don't pay a second resolution. */
295
+ function seedSubresourceVerdict(validatedUrl: string): void {
296
+ try {
297
+ const hostname = new URL(validatedUrl).hostname.toLowerCase();
298
+ subresourceDnsVerdicts.set(hostname, {
299
+ expires: Date.now() + SUBRESOURCE_DNS_VERDICT_TTL_MS,
300
+ verdict: Promise.resolve(null),
301
+ });
302
+ } catch {
303
+ /* unparseable URLs are rejected by the validator itself */
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Execute a scrape task
309
+ */
310
+ export async function executeScrapeTask(
311
+ _context: any,
312
+ url: string,
313
+ signal?: AbortSignal
314
+ ): Promise<{ contentType: string; html?: string; buffer?: Buffer; jitter: number }> {
315
+ const page = await createPageSafe(_context);
316
+ const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
317
+ page.setDefaultTimeout(SCRAPE_TIMEOUT);
318
+ page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
319
+
320
+ // Bind the task-timeout signal to this page: page.setDefaultTimeout only bounds
321
+ // Playwright's action/navigation verbs, NOT Response reads — a large/slow PDF's
322
+ // response.body() (or a slowloris trickle) has no ceiling and would pin the poolifier
323
+ // worker slot indefinitely past the task deadline. Closing the page on abort rejects
324
+ // any in-flight body()/content()/goto with 'Target closed', which the catch below
325
+ // handles, freeing the slot at the deadline. Fire-and-forget close; { once } so the
326
+ // listener is auto-removed.
327
+ const onAbort = () => { page.close().catch(() => {}); };
328
+ if (signal) {
329
+ if (signal.aborted) onAbort();
330
+ else signal.addEventListener('abort', onAbort, { once: true });
331
+ }
332
+
333
+ // Set by the response-side SSRF backstop (see page.on('response') below) when
334
+ // ANY response was served from a private/reserved address: the scrape is
335
+ // poisoned and must fail without returning content. Declared outside the try
336
+ // so the catch can surface it instead of the secondary 'Target closed' error
337
+ // produced by the poison-triggered page.close().
338
+ let poisonedError: Error | null = null;
339
+ const pendingAddrChecks: Array<Promise<void>> = [];
340
+
341
+ try {
342
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
343
+
344
+ // Intercept EVERY request and validate it against SSRF rules: navigations
345
+ // (server 3xx AND client-side meta-refresh / window.location / form submit)
346
+ // get the full DNS-aware check; subresources (img/script/xhr/fetch) get the
347
+ // SAME DNS-aware check, deduplicated per hostname by a short-TTL verdict
348
+ // cache so a page full of same-host assets costs one resolution, not one
349
+ // per asset. Without the DNS pass on subresources, a hostile page could
350
+ // fetch() a TTL-0 rebinding hostname that resolves to an internal IP /
351
+ // cloud metadata and read the response into the DOM that page.content()
352
+ // serializes into the research report.
353
+ // Must use page.route (a BLOCKING intercept), NOT page.on('request') — the
354
+ // latter does not pause navigation, so the abort raced the redirect and the
355
+ // metadata endpoint could be reached before it fired. route.fallback() defers
356
+ // to the next handler (the CI mock context.route) or the network, so this
357
+ // preserves mocking while actually gating each request.
358
+ const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } =
359
+ await import('../../web-research/scraper-utils.ts');
360
+ await page.route('**', async (route: any, req: any) => {
361
+ const reqUrl: string = req.url();
362
+ // Only http(s) requests can reach a private host over the network; in-page
363
+ // data:/blob:/about: schemes are not an SSRF vector and must pass through
364
+ // untouched (else pages using data: images/fonts would break).
365
+ if (/^https?:/i.test(reqUrl)) {
366
+ try {
367
+ if (req.isNavigationRequest()) {
368
+ // ANY navigation — a server 3xx redirect OR a client-side hop (meta
369
+ // refresh, window.location, form submit). Full DNS-aware check;
370
+ // navigations are infrequent so the resolution cost is negligible.
371
+ // Closes full-content SSRF exfil where a page redirects itself to
372
+ // 169.254.169.254 / an internal host and the rendered body is returned.
373
+ await validateForWorker(reqUrl);
374
+ // Same-origin assets of a just-validated document ride this verdict.
375
+ seedSubresourceVerdict(reqUrl);
376
+ } else {
377
+ // Subresource (img/script/style/xhr/fetch). Synchronous literal/
378
+ // pattern screen first: it is free, and a `true` return means the
379
+ // test-only loopback affordance definitively accepted the target
380
+ // (a DNS pass would wrongly reject localhost→127.0.0.1).
381
+ const loopbackAccepted = validateForWorkerSync(reqUrl);
382
+ if (!loopbackAccepted) {
383
+ // DNS-aware pass, one resolution per hostname per short TTL.
384
+ // Same-origin subresources are a cache hit (seeded by the
385
+ // navigation) — for those, the rebinding TOCTOU window is closed
386
+ // by the response-side serverAddr() backstop below, not here.
387
+ const verdictError = await getCachedSubresourceVerdict(reqUrl, validateForWorker);
388
+ if (verdictError) throw verdictError;
389
+ }
390
+ }
391
+ } catch (_ssrfErr: unknown) {
392
+ await route.abort('blockedbyclient').catch(() => {});
393
+ return;
394
+ }
395
+ }
396
+ await route.fallback().catch(() => {});
397
+ });
398
+
399
+ // Track the ACTUAL connected IP of each main-frame document response. The post-goto check below
400
+ // only re-validates the initial navigation; a page that passes it can then client-navigate
401
+ // (window.location / meta-refresh / form submit) to a TTL-0 host that rebinds to a private /
402
+ // metadata IP. Re-validating the FINAL main-frame document's serverAddr before its body is
403
+ // returned closes that second-hop DNS-rebind window.
404
+ //
405
+ // ADDITIONALLY: response-side rebinding backstop for EVERY response. The request-time DNS
406
+ // pass above is inherently TOCTOU-racy — Firefox resolves hostnames itself at connect time
407
+ // (camoufox has no connect-time IP pinning), so a TTL-0 resolver can answer public during
408
+ // validation and private at connect. Firefox's juggler reports the IP each network-served
409
+ // response ACTUALLY came from via serverAddr() (null for cached/route-fulfilled responses).
410
+ // If any response arrived from a private/loopback/link-local/metadata address that wasn't
411
+ // explicitly allowed (loopback test flag), internal data may already be in the DOM — poison
412
+ // the scrape: close the page (aborting the load) and fail with a 'Fetch blocked:' error,
413
+ // which isBenignScrapeFailure classifies as an expected per-URL outcome (debug, not ERROR).
414
+ let finalMainFrameAddr: Promise<{ ipAddress?: string } | null> = Promise.resolve(null);
415
+ page.on('response', (resp: any) => {
416
+ try {
417
+ if (resp.request().resourceType() === 'document' && resp.frame() === page.mainFrame()) {
418
+ finalMainFrameAddr = resp.serverAddr().catch(() => null);
419
+ }
420
+ const check: Promise<void> = Promise.resolve(resp.serverAddr())
421
+ .then((addr: { ipAddress?: string } | null) => {
422
+ const ip = addr?.ipAddress;
423
+ if (!ip || poisonedError) return;
424
+ try {
425
+ validateForWorkerSync(ip.includes(':') ? `http://[${ip}]/` : `http://${ip}/`);
426
+ } catch (cause: unknown) {
427
+ poisonedError = new Error(
428
+ 'Fetch blocked: a response was served from a private/reserved address (SSRF DNS-rebinding defense)',
429
+ { cause },
430
+ );
431
+ logToDebugFile('WARN', `[Worker-${workerId}] SSRF rebinding defense: a response connected to a private/reserved address; poisoning scrape of ${url}`);
432
+ // Abort the in-flight load; goto/content() reject with 'Target
433
+ // closed', and the catch below surfaces poisonedError instead.
434
+ page.close().catch(() => {});
435
+ }
436
+ })
437
+ .catch(() => {});
438
+ pendingAddrChecks.push(check);
439
+ } catch {
440
+ /* frame detached / teardown — ignore */
441
+ }
442
+ });
443
+
444
+ // High-fidelity wait: try domcontentloaded first for speed
445
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
446
+
447
+ // DNS-rebinding defense-in-depth. The page.route guard's validateForWorker resolves the
448
+ // hostname independently of the browser's own connect-time resolution, so a TTL-0 rebind can
449
+ // pass validation yet have the browser connect to a private/metadata IP (169.254.169.254 /
450
+ // RFC1918). The browser reports the IP it ACTUALLY connected to via serverAddr — re-check it
451
+ // against the same SSRF policy and refuse to return the body if it is internal. This does not
452
+ // prevent the TCP connection itself, but it stops the rendered response (e.g. cloud IAM
453
+ // credentials) from being exfiltrated to the researcher/report, which is the real harm here.
454
+ const serverAddr = await response?.serverAddr().catch(() => null);
455
+ if (serverAddr?.ipAddress) {
456
+ const ip: string = serverAddr.ipAddress;
457
+ validateForWorkerSync(ip.includes(':') ? `http://[${ip}]/` : `http://${ip}/`);
458
+ }
459
+
460
+ const contentType = (await response?.headerValue('content-type')) || '';
461
+
462
+ if (contentType.includes('application/pdf')) {
463
+ if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
464
+ const buffer = await response.body();
465
+ // Discard the bytes if any observed response came from a private address.
466
+ await Promise.allSettled(pendingAddrChecks);
467
+ if (poisonedError) throw poisonedError;
468
+ await page.close();
469
+ return { contentType, buffer, jitter: 0 };
470
+ }
471
+
472
+ // If it's HTML, check if we need to wait longer (JS-heavy sites)
473
+ let html = await page.content();
474
+
475
+ // DETECT CLOUDFLARE CHALLENGE
476
+ const cfPatterns = ['_cf_chl_', 'cdn-cgi/challenge-platform', 'cf_chl_opt', 'Just a moment...', 'Checking your browser before accessing'];
477
+ const hasCloudflare = cfPatterns.some((pattern: string) => html.includes(pattern));
478
+
479
+ if (hasCloudflare) {
480
+ logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge detected for: ${url}`);
481
+
482
+ // Wait up to 5 seconds for challenge to resolve
483
+ try {
484
+ await page.waitForFunction(
485
+ () => {
486
+ // @ts-ignore - document is available in browser context
487
+ // eslint-disable-next-line no-undef
488
+ const body = document.body.innerHTML;
489
+ return !body.includes('_cf_chl_') &&
490
+ !body.includes('cdn-cgi/challenge-platform') &&
491
+ !body.includes('cf_chl_opt') &&
492
+ !body.includes('Just a moment...') &&
493
+ !body.includes('Checking your browser before accessing') &&
494
+ body.length > 200; // Ensure content loaded
495
+ },
496
+ { timeout: 5000 }
497
+ );
498
+
499
+ // Challenge resolved, get updated content
500
+ html = await page.content();
501
+ logToDebugFile('INFO', `[Worker-${workerId}] Cloudflare challenge resolved for: ${url}`);
502
+ } catch (_waitError: unknown) {
503
+ // A Cloudflare challenge that doesn't clear is an expected block, not a worker
504
+ // fault (the thrown 'Fetch blocked' is demoted to WARN at the server boundary
505
+ // via isCloudflareBlockError). Label the debug-file line WARN to match.
506
+ logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge failed for: ${url}`);
507
+ const error = new Error('Fetch blocked: Cloudflare challenge');
508
+ error.cause = _waitError;
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ // Check if we need to wait longer for JS-heavy sites
514
+ const needsWait = html.length < 5000 ||
515
+ html.includes('id="root"') ||
516
+ html.includes('id="app"') ||
517
+ html.includes('<noscript>');
518
+
519
+ if (needsWait) {
520
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
521
+ html = await page.content();
522
+ }
523
+
524
+ // Skip jitter in mock mode; in real browsing, add 500–1500ms to mimic human behavior
525
+ const jitter = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true'
526
+ ? 0
527
+ : Math.floor(Math.random() * 1000) + 500;
528
+ if (jitter > 0) await new Promise(r => setTimeout(r, jitter));
529
+
530
+ // Re-validate the final rendered main-frame document's connected IP (catches a client-side
531
+ // navigation to a DNS-rebinding host that occurred after the initial goto passed).
532
+ const finalAddr = await finalMainFrameAddr;
533
+ if (finalAddr?.ipAddress) {
534
+ const fip: string = finalAddr.ipAddress;
535
+ validateForWorkerSync(fip.includes(':') ? `http://[${fip}]/` : `http://${fip}/`);
536
+ }
537
+
538
+ // Response-side SSRF backstop: settle every serverAddr() inspection recorded
539
+ // so far and refuse to return the captured HTML if any response — main frame
540
+ // OR subresource (the same-origin fetch() rebinding case) — was served from
541
+ // a private/reserved address.
542
+ await Promise.allSettled(pendingAddrChecks);
543
+ if (poisonedError) throw poisonedError;
544
+
545
+ await page.close();
546
+ return { contentType, html, jitter };
547
+ } catch (error) {
548
+ await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
549
+ // A poison-triggered page.close() makes the in-flight goto/content() reject
550
+ // with 'Target closed'; report the SSRF poisoning (a benign-classified
551
+ // 'Fetch blocked:' failure), not the secondary teardown error.
552
+ if (poisonedError) throw poisonedError;
553
+ throw error;
554
+ } finally {
555
+ signal?.removeEventListener('abort', onAbort);
556
+ }
557
+ }
558
+
559
+ // Primary health probe target: the live search provider. Verifying it is the most
560
+ // representative precondition for research, but DDG is also the endpoint most prone
561
+ // to bot-blocking/rate-limiting automated browser traffic — so a failure here is NOT
562
+ // proof the web is unreachable.
563
+ const HEALTH_PRIMARY_URL = 'https://lite.duckduckgo.com/lite/';
564
+ // Neutral, automation-tolerant reachability fallback (IANA-operated, no bot
565
+ // protection, returns a stable non-empty title). A successful load here proves the
566
+ // browser CAN reach the open web, which disambiguates "the search provider blocked
567
+ // or hiccupped" from a genuine connectivity outage.
568
+ const HEALTH_FALLBACK_URL = 'https://example.com/';
569
+
570
+ /**
571
+ * Execute a health check attempt against a given URL (defaults to the search provider).
572
+ */
573
+ async function executeHealthCheckAttempt(
574
+ _context: any,
575
+ navTimeoutMs: number,
576
+ url: string = HEALTH_PRIMARY_URL
577
+ ): Promise<{ success: boolean; navMs: number }> {
578
+ const page = await createPageSafe(_context);
579
+ page.setDefaultTimeout(navTimeoutMs);
580
+ page.setDefaultNavigationTimeout(navTimeoutMs);
581
+
582
+ try {
583
+ const navStart = Date.now();
584
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
585
+ const navMs = Date.now() - navStart;
586
+
587
+ if (navMs > 3000) {
588
+ logToDebugFile('WARN', `[Worker-${workerId}] Health navigation to ${url} was slow: ${navMs}ms (expected <1s). Possible rate-limit or network congestion.`);
589
+ }
590
+
591
+ const title = await page.title();
592
+ await page.close();
593
+ return { success: !!title, navMs };
594
+ } catch (error) {
595
+ await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
596
+ throw error;
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Execute a health check task
602
+ */
603
+ export async function executeHealthCheck(
604
+ _context: any,
605
+ initMs: number
606
+ ): Promise<{ success: boolean; navMs: number }> {
607
+ // Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
608
+ // The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
609
+ const configuredMs = parseInt(process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'] || '0', 10);
610
+ const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(10000, configuredMs) : 10000;
611
+
612
+ if (initMs > 3000) {
613
+ logToDebugFile('WARN', `[Worker-${workerId}] Browser init was slow: ${initMs}ms — leaving less headroom for DDG navigation.`);
614
+ }
615
+
616
+ try {
617
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
618
+ } catch (firstError: unknown) {
619
+ // One retry: if the first attempt fails, the page may have been in a bad state
620
+ // (challenge redirect, partial load, transient network blip). A second attempt
621
+ // on a fresh page helps distinguish a real outage from a one-off failure.
622
+ logToDebugFile('WARN', `[Worker-${workerId}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
623
+ try {
624
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
625
+ } catch (retryError: unknown) {
626
+ const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
627
+ // A TIMEOUT is left to propagate: upstream (isBusyPoolHealthFailure) already
628
+ // treats timed-out probes as a congested-but-live pool and proceeds, and
629
+ // re-probing would only add latency to an already-slow path.
630
+ const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
631
+ if (!isTimeout) {
632
+ // The search provider failed twice with a non-timeout (e.g. net::ERR*) error.
633
+ // That is most often DDG-specific bot-blocking, NOT a dead connection — the
634
+ // observed false negative where the open web was fully reachable yet research
635
+ // aborted with "check your internet". Confirm raw web reachability against a
636
+ // neutral endpoint before declaring failure; if it loads, the browser is
637
+ // healthy enough to research (search has its own retries/fallbacks downstream).
638
+ logToDebugFile('WARN', `[Worker-${workerId}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
639
+ try {
640
+ const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
641
+ logToDebugFile('WARN', `[Worker-${workerId}] Fallback endpoint reachable — open web is up, search provider is degraded. Proceeding with research.`);
642
+ return fallback;
643
+ } catch (fallbackError: unknown) {
644
+ logToDebugFile('ERROR', `[Worker-${workerId}] Health check failed after retry + fallback probe: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
645
+ throw retryError;
646
+ }
647
+ }
648
+ logToDebugFile('ERROR', `[Worker-${workerId}] Health check failed after retry: ${retryMsg}`);
649
+ throw retryError;
650
+ }
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Check if browser error requires reset
656
+ */
657
+ export function shouldResetBrowser(errorMsg: string): boolean {
658
+ return errorMsg.includes('Target closed') ||
659
+ errorMsg.includes('browser has disconnected') ||
660
+ errorMsg.includes('Protocol error') ||
661
+ errorMsg.includes('Session closed');
662
+ }