@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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Execution Service
|
|
3
|
+
*
|
|
4
|
+
* Handles task execution with retry logic and circuit breaker.
|
|
5
|
+
* Refactored from browser-manager.ts for better separation of concerns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Config } from '../../config.ts';
|
|
9
|
+
import type { SearchResult } from '../../web-research/types.ts';
|
|
10
|
+
import type { BrowserTask } from '../../types/index.ts';
|
|
11
|
+
import { logger } from '../../logger.ts';
|
|
12
|
+
import { errorTracker } from '../../utils/error-tracker.ts';
|
|
13
|
+
import { getBrowserCircuitBreaker, browserCircuitBreaker, isTransientSocketError, isPoolShutdownError, isTaskTimeoutError, isCloudflareBlockError } from './browser-error-utils.ts';
|
|
14
|
+
import { getScheduler as _getScheduler, forceSchedulerRestart as _forceSchedulerRestart } from './scheduler-factory.ts';
|
|
15
|
+
import { waitForBrowserPoolIdle } from './browser-lifecycle.ts';
|
|
16
|
+
import { getServiceContainer, getService } from '../../core/service-registry.ts';
|
|
17
|
+
import type { ServiceContainer } from '../../core/service-registry.ts';
|
|
18
|
+
import { ServiceNames } from '../../core/service-interfaces.ts';
|
|
19
|
+
import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
|
|
20
|
+
|
|
21
|
+
// Cooldown to prevent cascading scheduler restarts (thundering herd)
|
|
22
|
+
let lastRestartTime = 0;
|
|
23
|
+
const RESTART_COOLDOWN_MS = 10000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the scheduler from the factory, optionally using a specific container.
|
|
27
|
+
*/
|
|
28
|
+
async function getScheduler(config?: Config, container: ServiceContainer = getServiceContainer()): Promise<IScheduler> {
|
|
29
|
+
try {
|
|
30
|
+
const factory = await getService<ISchedulerFactory>(ServiceNames.SCHEDULER_FACTORY, undefined, container);
|
|
31
|
+
return await factory.getScheduler(config);
|
|
32
|
+
} catch {
|
|
33
|
+
// Fallback to factory function if service resolution fails (e.g. during early init or tests)
|
|
34
|
+
return await _getScheduler(config, container);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Force a scheduler restart, optionally using a specific container.
|
|
40
|
+
*/
|
|
41
|
+
async function forceSchedulerRestart(forceClearRemoteState: boolean = false, container: ServiceContainer = getServiceContainer()): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
const factory = await getService<ISchedulerFactory>(ServiceNames.SCHEDULER_FACTORY, undefined, container);
|
|
44
|
+
return await factory.forceSchedulerRestart(forceClearRemoteState);
|
|
45
|
+
} catch {
|
|
46
|
+
return await _forceSchedulerRestart(forceClearRemoteState, container);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Dispatches a browser task to the unified worker pool.
|
|
52
|
+
*/
|
|
53
|
+
export async function runBrowserTask<T>(
|
|
54
|
+
taskOrUrl: string | BrowserTask,
|
|
55
|
+
type: 'search' | 'scrape' = 'scrape',
|
|
56
|
+
config?: Config,
|
|
57
|
+
signal?: AbortSignal,
|
|
58
|
+
retries = 1,
|
|
59
|
+
container: ServiceContainer = getServiceContainer()
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
62
|
+
|
|
63
|
+
// Extract sessionId for circuit breaker scoping
|
|
64
|
+
const sessionId = typeof taskOrUrl === 'object' ? taskOrUrl.sessionId : undefined;
|
|
65
|
+
const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
return await breaker.execute(async () => {
|
|
69
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
70
|
+
const scheduler = await getScheduler(config, container);
|
|
71
|
+
if (type === 'search') {
|
|
72
|
+
const query = typeof taskOrUrl === 'string' ? taskOrUrl : (taskOrUrl as BrowserTask).query;
|
|
73
|
+
if (!query) throw new Error('Search task requires a query');
|
|
74
|
+
return (await scheduler.runSearch(query, config, signal)) as T;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const url = typeof taskOrUrl === 'string' ? taskOrUrl : (taskOrUrl as BrowserTask).url;
|
|
78
|
+
if (url) {
|
|
79
|
+
return (await scheduler.runScrape(url, config, signal)) as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error('Unified browser manager requires data-driven tasks (URLs/Queries)');
|
|
83
|
+
});
|
|
84
|
+
} catch (error: unknown) {
|
|
85
|
+
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
86
|
+
|
|
87
|
+
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
88
|
+
errorTracker.trackError(error, {
|
|
89
|
+
component: 'browser-manager',
|
|
90
|
+
operation: type,
|
|
91
|
+
taskType: type,
|
|
92
|
+
errorType: 'transient_socket_error',
|
|
93
|
+
});
|
|
94
|
+
logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
95
|
+
|
|
96
|
+
if (isPoolShutdownError(error)) {
|
|
97
|
+
// Pool is temporarily draining — wait for the drain to finish.
|
|
98
|
+
logger.warn(`[BrowserManager] Pool is draining — waiting for pool idle before retry...`);
|
|
99
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
100
|
+
} else {
|
|
101
|
+
// True socket/connection error — restart the scheduler with thundering herd guard.
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
104
|
+
lastRestartTime = now;
|
|
105
|
+
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
106
|
+
await forceSchedulerRestart(true, container);
|
|
107
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
108
|
+
} else {
|
|
109
|
+
logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
|
|
110
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Retry with jitter (100-500ms) to prevent thundering herd
|
|
115
|
+
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
117
|
+
return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container);
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run a browser health check with retry logic.
|
|
125
|
+
*/
|
|
126
|
+
export async function runBrowserHealthCheck(config?: Config, retries = 1, signal?: AbortSignal, container: ServiceContainer = getServiceContainer()): Promise<{ success: boolean }> {
|
|
127
|
+
try {
|
|
128
|
+
return await browserCircuitBreaker.execute(async () => {
|
|
129
|
+
const scheduler = await getScheduler(config, container);
|
|
130
|
+
return await scheduler.runHealthCheck(config, signal);
|
|
131
|
+
});
|
|
132
|
+
} catch (error: unknown) {
|
|
133
|
+
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
134
|
+
errorTracker.trackError(error, {
|
|
135
|
+
component: 'browser-manager',
|
|
136
|
+
operation: 'healthcheck',
|
|
137
|
+
errorType: 'transient_socket_error',
|
|
138
|
+
});
|
|
139
|
+
logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
140
|
+
|
|
141
|
+
if (isPoolShutdownError(error)) {
|
|
142
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
143
|
+
} else {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
146
|
+
lastRestartTime = now;
|
|
147
|
+
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
148
|
+
await forceSchedulerRestart(true, container);
|
|
149
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
150
|
+
} else {
|
|
151
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
156
|
+
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
157
|
+
return runBrowserHealthCheck(config, retries - 1, signal, container);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Run a worker search query with retry logic.
|
|
165
|
+
*/
|
|
166
|
+
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<SearchResult[]> {
|
|
167
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
168
|
+
|
|
169
|
+
const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
return await breaker.execute(async () => {
|
|
173
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
174
|
+
const scheduler = await getScheduler(config, container);
|
|
175
|
+
return await scheduler.runSearch(query, config, signal);
|
|
176
|
+
});
|
|
177
|
+
} catch (error: unknown) {
|
|
178
|
+
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
179
|
+
|
|
180
|
+
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
181
|
+
errorTracker.trackError(error, {
|
|
182
|
+
component: 'browser-manager',
|
|
183
|
+
operation: 'search',
|
|
184
|
+
query,
|
|
185
|
+
errorType: 'transient_socket_error',
|
|
186
|
+
});
|
|
187
|
+
logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
|
|
188
|
+
|
|
189
|
+
if (isPoolShutdownError(error)) {
|
|
190
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
191
|
+
} else {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
194
|
+
lastRestartTime = now;
|
|
195
|
+
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
196
|
+
await forceSchedulerRestart(true, container);
|
|
197
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
198
|
+
} else {
|
|
199
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
204
|
+
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
205
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Worker Browser Management
|
|
3
|
+
*
|
|
4
|
+
* Handles browser initialization, context management, and cleanup.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: This file uses logToDebugFile() instead of the main-process logger because
|
|
7
|
+
* worker threads run in separate isolated processes. All diagnostic output is
|
|
8
|
+
* written to the file path in PI_RESEARCH_LOG_FILE (set by the worker pool manager).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { platform } from 'node:os';
|
|
12
|
+
import { setupMocking } from './thread-worker-messaging.ts';
|
|
13
|
+
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
14
|
+
import { resolveHeadlessMode } from './config.ts';
|
|
15
|
+
|
|
16
|
+
let browser: any = null;
|
|
17
|
+
let context: any = null;
|
|
18
|
+
let initPromise: Promise<void> | null = null;
|
|
19
|
+
let workerId: string = '';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Set the worker ID for logging purposes
|
|
23
|
+
*/
|
|
24
|
+
export function setWorkerId(id: string): void {
|
|
25
|
+
workerId = id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if browser is connected
|
|
30
|
+
*/
|
|
31
|
+
const isBrowserConnected = () => {
|
|
32
|
+
try {
|
|
33
|
+
return browser && typeof browser.isConnected === 'function' && browser.isConnected();
|
|
34
|
+
} catch {
|
|
35
|
+
logToDebugFile('DEBUG', '[ThreadWorker] browser.isConnected check threw, treating as disconnected');
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Log to the worker debug file (PI_RESEARCH_LOG_FILE).
|
|
42
|
+
* This is the only logging mechanism available to worker processes —
|
|
43
|
+
* the main-process logger is not accessible from isolated worker threads.
|
|
44
|
+
*/
|
|
45
|
+
function logToDebugFile(level: string, ...args: any[]): void {
|
|
46
|
+
const logFile = process.env['PI_RESEARCH_LOG_FILE'];
|
|
47
|
+
if (!logFile) return;
|
|
48
|
+
// Match the main-process logger: WARN/ERROR are always recorded (crash diagnostics);
|
|
49
|
+
// INFO/DEBUG only when verbose. Without this gate a non-verbose scaling run still
|
|
50
|
+
// streamed thousands of worker DEBUG lines to PI_RESEARCH_LOG_FILE — often a tmpfs
|
|
51
|
+
// (RAM) path — adding memory and I/O pressure for no benefit during sustained loops.
|
|
52
|
+
if ((level === 'INFO' || level === 'DEBUG') && process.env['PI_RESEARCH_DEBUG'] !== 'true') return;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const timestamp = new Date().toISOString();
|
|
56
|
+
const entry = {
|
|
57
|
+
timestamp,
|
|
58
|
+
level,
|
|
59
|
+
workerId,
|
|
60
|
+
message: redactSecrets(args.map(arg => {
|
|
61
|
+
if (arg instanceof Error) return arg.stack || arg.message;
|
|
62
|
+
if (typeof arg === 'object' && arg !== null) return JSON.stringify(arg);
|
|
63
|
+
return String(arg);
|
|
64
|
+
}).join(' '))
|
|
65
|
+
};
|
|
66
|
+
// FIX (#32): Use async fs.appendFile to avoid blocking the event loop.
|
|
67
|
+
import('node:fs/promises').then(fsp => fsp.appendFile(logFile, `${JSON.stringify(entry)}\n`)).catch(() => {});
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Initialize the browser instance
|
|
75
|
+
*/
|
|
76
|
+
export async function initBrowser(): Promise<void> {
|
|
77
|
+
if (isBrowserConnected() && context) return;
|
|
78
|
+
if (initPromise) return initPromise;
|
|
79
|
+
|
|
80
|
+
initPromise = (async () => {
|
|
81
|
+
try {
|
|
82
|
+
if (!isBrowserConnected() || !context) {
|
|
83
|
+
logToDebugFile('INFO', `[Worker-${workerId}] Initializing browser instance...`);
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------
|
|
86
|
+
// Pinned browser stack — DO NOT caret-bump these three in isolation.
|
|
87
|
+
// They are one coupled set (see package.json + docs/ARCHITECTURE.md).
|
|
88
|
+
// Each was a real fresh-install/runtime break that our lockfile masked
|
|
89
|
+
// (consumers install without our lockfile, so a floating range resolves
|
|
90
|
+
// to a newer, broken version):
|
|
91
|
+
//
|
|
92
|
+
// • camoufox-js ^0.10.2 → fetches camoufox Firefox 135 / beta.24, the
|
|
93
|
+
// NEWEST camoufox with binaries for every OS we support (Linux x64/
|
|
94
|
+
// arm64, macOS x64/arm64, Windows x64). FF146/FF150 dropped the
|
|
95
|
+
// Windows build entirely, so the 0.11.x line breaks Windows installs.
|
|
96
|
+
// • playwright-core 1.60.0 (exact) → playwright-core and the camoufox
|
|
97
|
+
// binary are one matched Juggler-protocol pair. 1.61 added a
|
|
98
|
+
// viewport.isMobile field the FF135 build REJECTS ("property
|
|
99
|
+
// viewport.isMobile not described in this scheme"), failing every
|
|
100
|
+
// launch below. 1.60.0 is the newest playwright the FF135 Juggler
|
|
101
|
+
// accepts.
|
|
102
|
+
// • impit 0.13.0 (exact, a direct dep though only camoufox-js uses it)
|
|
103
|
+
// → impit 0.13.1/0.14.0 shipped a `preinstall: npx only-allow pnpm`
|
|
104
|
+
// guard (an upstream mistake, fixed in 0.14.1+) that hard-fails
|
|
105
|
+
// `npm install -g`. Within camoufox-js's ^0.13.0 range 0.13.0 is the
|
|
106
|
+
// only guard-free version; a direct-dep pin is the ONLY way to force
|
|
107
|
+
// it for consumers (npm `overrides` don't propagate to installers of
|
|
108
|
+
// a published package).
|
|
109
|
+
//
|
|
110
|
+
// Removal: upgrade all three together when camoufox ships stable
|
|
111
|
+
// cross-platform (incl. Windows) FF150 binaries — bumping camoufox-js to
|
|
112
|
+
// ≥0.11.1 also drops the impit pin (0.11.1 requires the fixed impit
|
|
113
|
+
// ^0.14.1) and needs playwright re-pinned to the new build's Juggler.
|
|
114
|
+
// Always re-verify a real headless run on Linux/macOS/Windows after.
|
|
115
|
+
let CamoufoxModule: any;
|
|
116
|
+
try {
|
|
117
|
+
CamoufoxModule = await import('camoufox-js');
|
|
118
|
+
} catch (e: unknown) {
|
|
119
|
+
throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { Camoufox } = CamoufoxModule;
|
|
123
|
+
|
|
124
|
+
// Launch browser without user_data_dir so Playwright creates an isolated
|
|
125
|
+
// temp profile per instance. This avoids persistent-context semantics
|
|
126
|
+
// (where context.browser() returns null) and the profile-lock contention
|
|
127
|
+
// that came with sharing a single user_data_dir path.
|
|
128
|
+
// Camoufox provides built-in fingerprint spoofing via humanize, os, locale,
|
|
129
|
+
// screen, and geoip options. The addons[] option is for custom Firefox
|
|
130
|
+
// extension directories only (not named strings like 'stealth').
|
|
131
|
+
// Camoufox's built-in fingerprinting handles UA, timezone, geolocation, etc.
|
|
132
|
+
// coherently. Manual overrides for those via launch options are silently
|
|
133
|
+
// ignored by Playwright; set them at context level if needed, or rely on
|
|
134
|
+
// camoufox defaults for a consistent, coordinated fingerprint.
|
|
135
|
+
//
|
|
136
|
+
// 90s launch timeout: CI runners (2 vCPU) need up to 60s to start Firefox
|
|
137
|
+
// when it has both CPUs. The old 45s limit was too tight and caused silent
|
|
138
|
+
// init failures under resource pressure.
|
|
139
|
+
const launchTimeoutMs = 90000;
|
|
140
|
+
const launchOnce = async (headless: boolean | 'virtual') => {
|
|
141
|
+
const launchPromise = Camoufox({
|
|
142
|
+
headless,
|
|
143
|
+
humanize: true,
|
|
144
|
+
locale: 'en-US',
|
|
145
|
+
screen: {
|
|
146
|
+
width: 1920,
|
|
147
|
+
height: 1080,
|
|
148
|
+
colorDepth: 24,
|
|
149
|
+
pixelRatio: 1,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
// Guard with a hard timeout. We catch the launchPromise rejection so it
|
|
153
|
+
// doesn't surface as an UnhandledPromiseRejection if it rejects after the
|
|
154
|
+
// timeout races ahead.
|
|
155
|
+
launchPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
156
|
+
let launchTimeoutId: NodeJS.Timeout | undefined;
|
|
157
|
+
try {
|
|
158
|
+
return await Promise.race([
|
|
159
|
+
launchPromise,
|
|
160
|
+
new Promise<never>((_, reject) => {
|
|
161
|
+
launchTimeoutId = setTimeout(() => reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`)), launchTimeoutMs);
|
|
162
|
+
})
|
|
163
|
+
]);
|
|
164
|
+
} finally {
|
|
165
|
+
if (launchTimeoutId) clearTimeout(launchTimeoutId);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Prefer true headless (no visible window — see resolveHeadlessMode).
|
|
170
|
+
// Windows safety net: on a minority of Windows machines camoufox crashes
|
|
171
|
+
// immediately in headless mode (exit 0x80000003 / STATUS_BREAKPOINT,
|
|
172
|
+
// camoufox issue #614). If the headless launch fails for a non-timeout
|
|
173
|
+
// reason there, fall back ONCE to a headed (visible) window so research
|
|
174
|
+
// still works on those machines. Everywhere else the error propagates.
|
|
175
|
+
const primaryHeadless = resolveHeadlessMode();
|
|
176
|
+
let launchedBrowser: Awaited<ReturnType<typeof launchOnce>>;
|
|
177
|
+
try {
|
|
178
|
+
launchedBrowser = await launchOnce(primaryHeadless);
|
|
179
|
+
} catch (launchErr: unknown) {
|
|
180
|
+
const lmsg = launchErr instanceof Error ? launchErr.message : String(launchErr);
|
|
181
|
+
const wasTimeout = lmsg.includes('timed out');
|
|
182
|
+
if (platform() === 'win32' && primaryHeadless === true && !wasTimeout) {
|
|
183
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Headless browser launch failed on Windows (${lmsg}); falling back to a visible window so scraping still works (camoufox issue #614).`);
|
|
184
|
+
launchedBrowser = await launchOnce(false);
|
|
185
|
+
} else {
|
|
186
|
+
throw launchErr;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
browser = launchedBrowser;
|
|
191
|
+
// newContext() can hang if the browser process becomes unresponsive immediately
|
|
192
|
+
// after launch (e.g. OOM, GPU crash). Guard with a hard timeout.
|
|
193
|
+
const contextPromise = browser.newContext();
|
|
194
|
+
contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
195
|
+
let contextTimeoutId: NodeJS.Timeout | undefined;
|
|
196
|
+
context = await Promise.race([
|
|
197
|
+
contextPromise,
|
|
198
|
+
new Promise<never>((_, reject) => {
|
|
199
|
+
contextTimeoutId = setTimeout(() => reject(new Error('Browser context creation timed out after 30000ms')), 30000);
|
|
200
|
+
})
|
|
201
|
+
]);
|
|
202
|
+
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
203
|
+
|
|
204
|
+
// Setup mocking for CI if enabled
|
|
205
|
+
await setupMocking(context);
|
|
206
|
+
|
|
207
|
+
logToDebugFile('INFO', `[Worker-${workerId}] Browser initialized.`);
|
|
208
|
+
}
|
|
209
|
+
} catch (e: unknown) {
|
|
210
|
+
// Close any partially-launched browser to avoid orphaning the process.
|
|
211
|
+
// Promise.resolve() wraps the call safely: in headless:'virtual' mode,
|
|
212
|
+
// camoufox-js browser.close() is synchronous (returns void), not a Promise.
|
|
213
|
+
if (browser && typeof browser.close === 'function') {
|
|
214
|
+
Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during failed init: ${err.message}`));
|
|
215
|
+
}
|
|
216
|
+
browser = null;
|
|
217
|
+
context = null;
|
|
218
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
219
|
+
|
|
220
|
+
if (msg.includes('Camoufox is not installed') || msg.includes('Version information not found')) {
|
|
221
|
+
throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// camoufox headless: "virtual" spawns Xvfb on Linux when no DISPLAY is set.
|
|
225
|
+
// CannotFindXvfb is thrown when `which Xvfb` fails (xvfb not installed).
|
|
226
|
+
const errName = e instanceof Error ? (e as any).constructor?.name ?? '' : '';
|
|
227
|
+
if (errName === 'CannotFindXvfb' || errName === 'CannotExecuteXvfb' || msg.includes('Xvfb') || msg.includes('virtual display')) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
'[Worker] No display server found on Linux. Install Xvfb for headless use (TTY/Wayland): sudo apt install xvfb',
|
|
230
|
+
{ cause: e }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw e;
|
|
235
|
+
} finally {
|
|
236
|
+
initPromise = null;
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
|
|
240
|
+
return initPromise;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get the current browser context
|
|
245
|
+
*/
|
|
246
|
+
export function getContext(): any {
|
|
247
|
+
return context;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Reset browser and context (used after crashes)
|
|
252
|
+
*/
|
|
253
|
+
export function resetBrowser(): void {
|
|
254
|
+
if (context) Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during reset: ${err.message}`));
|
|
255
|
+
if (browser) Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during reset: ${err.message}`));
|
|
256
|
+
context = null;
|
|
257
|
+
browser = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Clean up browser resources with a timeout to prevent hanging
|
|
262
|
+
*/
|
|
263
|
+
export async function cleanupBrowser(): Promise<void> {
|
|
264
|
+
const timeoutMs = 2000;
|
|
265
|
+
|
|
266
|
+
const cleanup = async () => {
|
|
267
|
+
if (context) await Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during cleanup: ${err.message}`));
|
|
268
|
+
if (browser) await Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during cleanup: ${err.message}`));
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await Promise.race([
|
|
273
|
+
cleanup(),
|
|
274
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser cleanup timed out')), timeoutMs))
|
|
275
|
+
]);
|
|
276
|
+
} catch {
|
|
277
|
+
// Ignore timeout — we just want to ensure cleanup doesn't hang indefinitely
|
|
278
|
+
} finally {
|
|
279
|
+
context = null;
|
|
280
|
+
browser = null;
|
|
281
|
+
}
|
|
282
|
+
}
|