@lincoln504/pi-research 0.1.13 → 1.0.1

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 +55 -138
  3. package/dist/cli.mjs +23375 -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 +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -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 +1195 -0
  30. package/src/config.ts +1032 -155
  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 +258 -11
  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 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  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
@@ -1,1183 +0,0 @@
1
- /**
2
- * SearXNG Container Manager - Simplified Design
3
- *
4
- * Singleton-style SearXNG container management shared by research sessions.
5
- *
6
- * DESIGN PRINCIPLES:
7
- * 1. Shared container reuse across research sessions
8
- * 2. Lazy startup on first use
9
- * 3. Explicit extension-owned cleanup when the extension shuts down
10
- * 4. Simple error recovery - if container is gone, recreate it
11
- * 5. No AutoRemove - manage cleanup explicitly
12
- * 6. No complex reuse logic - just check for container by name
13
- * 7. Manage Docker images - pull latest, prune old images on start
14
- */
15
-
16
- import Docker from 'dockerode';
17
- import * as fs from 'node:fs';
18
- import * as net from 'node:net';
19
- import * as path from 'node:path';
20
- import { StateManager } from './state-manager.ts';
21
- import { logger } from '../logger.ts';
22
-
23
- // Docker type definitions
24
- interface DockerContainerInspectInfo {
25
- State: {
26
- Running: boolean;
27
- Status: string;
28
- };
29
- NetworkSettings: {
30
- Ports: Record<string, Array<{ HostPort: string }> | null> | null;
31
- };
32
- }
33
-
34
- interface DockerContainerListInfo {
35
- Id: string;
36
- Names: string[];
37
- Image: string;
38
- State: string;
39
- Status: string;
40
- RepoTags?: string[];
41
- }
42
-
43
- interface DockerImageInfo {
44
- Id: string;
45
- RepoTags?: string[];
46
- Created: number;
47
- }
48
-
49
- interface DockerContainerCreateOptions {
50
- name: string;
51
- Image: string;
52
- ExposedPorts: Record<string, {}>;
53
- HostConfig: {
54
- PortBindings: Record<string, Array<{ HostPort: string }>>;
55
- Binds: string[];
56
- AutoRemove: boolean;
57
- ExtraHosts?: string[];
58
- };
59
- Env: string[];
60
- Labels: Record<string, string>;
61
- }
62
-
63
- export const DOCKER_HOST_INTERNAL_HOSTNAME = 'host.docker.internal';
64
-
65
- // Extension context type
66
- interface ExtensionContext {
67
- ui?: {
68
- notify(_message: string, _type?: 'info' | 'warning' | 'error'): void;
69
- };
70
- }
71
-
72
- // Default configuration
73
- const DEFAULT_CONFIG = {
74
- port: 55732,
75
- imageName: 'searxng/searxng',
76
- imageTag: 'latest',
77
- healthTimeout: 120000,
78
- pruneOldImages: true,
79
- containerName: 'pi-searxng',
80
- } as const;
81
-
82
- /**
83
- * Configuration from environment variables
84
- */
85
- interface EnvConfig {
86
- port?: number;
87
- imageName?: string;
88
- imageTag?: string;
89
- healthTimeout?: number;
90
- containerName?: string;
91
- }
92
-
93
- type DockerConnectionOptions = ConstructorParameters<typeof Docker>[0];
94
-
95
- function toDockerBindPath(p: string): string {
96
- return process.platform === 'win32' ? p.replace(/\\/g, '/') : p;
97
- }
98
-
99
- function normalizeSocketPath(socketPath: string): string {
100
- const trimmed = socketPath.trim();
101
- if (trimmed.startsWith('\\\\.\\pipe\\')) {
102
- return `//./pipe/${trimmed.slice('\\\\.\\pipe\\'.length)}`;
103
- }
104
- return trimmed;
105
- }
106
-
107
- function parseDockerHost(host: string): DockerConnectionOptions {
108
- if (host.startsWith('unix://')) {
109
- return { socketPath: host.slice('unix://'.length) };
110
- }
111
-
112
- if (host.startsWith('npipe://')) {
113
- return { socketPath: normalizeSocketPath(host.slice('npipe://'.length)) };
114
- }
115
-
116
- const url = new URL(host);
117
- const protocol = url.protocol.replace(':', '');
118
- return {
119
- host: url.hostname,
120
- port: url.port ? parseInt(url.port, 10) : undefined,
121
- protocol: protocol === 'https' ? 'https' : protocol === 'ssh' ? 'ssh' : 'http',
122
- };
123
- }
124
-
125
- export function getDockerConnectionCandidates(
126
- env: NodeJS.ProcessEnv = process.env,
127
- ): DockerConnectionOptions[] {
128
- const candidates: DockerConnectionOptions[] = [];
129
- const socketOverride = env['DOCKER_SOCKET']?.trim();
130
- if (socketOverride) {
131
- candidates.push({ socketPath: normalizeSocketPath(socketOverride) });
132
- }
133
-
134
- const hostOverride = env['DOCKER_HOST']?.trim();
135
- if (hostOverride) {
136
- try {
137
- candidates.push(parseDockerHost(hostOverride));
138
- } catch {
139
- // Ignore malformed DOCKER_HOST and fall through to platform defaults.
140
- }
141
- }
142
-
143
- if (process.platform === 'win32') {
144
- candidates.push(
145
- { socketPath: '//./pipe/docker_engine' },
146
- { socketPath: '//./pipe/dockerDesktopLinuxEngine' },
147
- { socketPath: '//./pipe/dockerDesktopWindowsEngine' },
148
- );
149
- } else {
150
- candidates.push({ socketPath: '/var/run/docker.sock' });
151
- }
152
-
153
- const seen = new Set<string>();
154
- return candidates.filter((candidate) => {
155
- const key = JSON.stringify(candidate);
156
- if (seen.has(key)) {
157
- return false;
158
- }
159
- seen.add(key);
160
- return true;
161
- });
162
- }
163
-
164
- export function getDockerHostGatewayExtraHosts(platform: NodeJS.Platform = process.platform): string[] {
165
- return platform === 'linux' ? [`${DOCKER_HOST_INTERNAL_HOSTNAME}:host-gateway`] : [];
166
- }
167
-
168
- /**
169
- * Load configuration from environment variables
170
- */
171
- function loadConfigFromEnv(): EnvConfig {
172
- const config: EnvConfig = {};
173
-
174
- const healthTimeoutEnv = process.env['PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS'];
175
- if (healthTimeoutEnv !== undefined) {
176
- const parsed = parseInt(healthTimeoutEnv, 10);
177
- if (!isNaN(parsed)) {
178
- config.healthTimeout = parsed;
179
- }
180
- }
181
-
182
- const imageTagEnv = process.env['SEARXNG_IMAGE_TAG']?.trim();
183
- if (imageTagEnv) {
184
- config.imageTag = imageTagEnv;
185
- }
186
-
187
- return config;
188
- }
189
-
190
- /**
191
- * Sleep for ms milliseconds
192
- */
193
- function sleep(ms: number): Promise<void> {
194
- return new Promise<void>((resolve) => setTimeout(resolve, ms));
195
- }
196
-
197
- /**
198
- * Check if a TCP port is accepting connections
199
- */
200
- function isPortListening(port: number, host: string = '127.0.0.1'): Promise<boolean> {
201
- return new Promise<boolean>((resolve) => {
202
- const socket: net.Socket = new net.Socket();
203
-
204
- socket.setTimeout(2000); // 2 second timeout
205
-
206
- socket.on('connect', () => {
207
- socket.destroy();
208
- resolve(true);
209
- });
210
-
211
- socket.on('timeout', () => {
212
- socket.destroy();
213
- resolve(false);
214
- });
215
-
216
- socket.on('error', () => {
217
- socket.destroy();
218
- resolve(false);
219
- });
220
-
221
- socket.connect(port, host);
222
- });
223
- }
224
-
225
- /**
226
- * SearxngManagerConfig type
227
- */
228
- export interface SearxngManagerConfig {
229
- port?: number;
230
- settingsPath?: string; // Custom settings file path (for Tor mode)
231
- imageName?: string;
232
- imageTag?: string;
233
- healthTimeout?: number;
234
- pruneOldImages?: boolean;
235
- containerName?: string;
236
- stateDir?: string;
237
- }
238
-
239
- /**
240
- * SearxngContainerInfo type
241
- */
242
- export interface SearxngContainerInfo {
243
- id: string;
244
- name: string;
245
- port: number;
246
- url: string;
247
- }
248
-
249
- /**
250
- * SearxngStatus type
251
- */
252
- export interface SearxngStatus {
253
- dockerAvailable: boolean;
254
- healthy: boolean;
255
- containerId?: string;
256
- containerName?: string;
257
- port?: number;
258
- url?: string;
259
- error?: string;
260
- }
261
-
262
- /**
263
- * DockerSearxngManager Class
264
- *
265
- * Simplified SearXNG Docker container manager.
266
- * Session-scoped lifecycle with deterministic container naming.
267
- * Also manages Docker images (pulls latest, prunes old images).
268
- */
269
- /**
270
- * Verify Docker is installed and daemon is responsive
271
- */
272
- export async function verifyDockerInstalled(): Promise<{ installed: boolean; running: boolean; error?: string }> {
273
- let lastError = 'Docker daemon is not running. Please start Docker.';
274
-
275
- for (const candidate of getDockerConnectionCandidates()) {
276
- try {
277
- const docker = new Docker(candidate);
278
- await docker.ping();
279
- return { installed: true, running: true };
280
- } catch (error) {
281
- lastError = error instanceof Error ? error.message : String(error);
282
- }
283
- }
284
-
285
- if (
286
- lastError.includes('ENOENT') ||
287
- lastError.includes('ECONNREFUSED') ||
288
- lastError.includes('The system cannot find the file specified') ||
289
- lastError.includes('EACCES') ||
290
- lastError.includes('permission')
291
- ) {
292
- let errorMsg = 'Docker daemon is not running or inaccessible.';
293
- if (process.platform === 'linux') {
294
- errorMsg += ' On Linux, run: sudo systemctl start docker && sudo usermod -aG docker $USER. Then log out and back in.';
295
- } else if (process.platform === 'darwin') {
296
- errorMsg += ' Start Docker Desktop from Applications.';
297
- } else if (process.platform === 'win32') {
298
- errorMsg += ' Start Docker Desktop from the Start menu.';
299
- }
300
- return { installed: true, running: false, error: errorMsg };
301
- }
302
-
303
- return { installed: false, running: false, error: `Docker not found or inaccessible: ${lastError}` };
304
- }
305
-
306
- export class DockerSearxngManager {
307
- private readonly docker: Docker | null = null;
308
- private container: Docker.Container | null = null;
309
- private containerInfo: SearxngContainerInfo | null = null;
310
- private readonly config: Required<SearxngManagerConfig>;
311
- private ctx: ExtensionContext | null = null;
312
- private sessionId: string | null = null;
313
- private starting: Promise<void> | null = null;
314
- private readonly stateManager: StateManager;
315
- private readonly _extensionDir: string;
316
- private readonly _settingsPath: string;
317
- private containerStartupLock: Promise<void> | null = null;
318
- private heartbeatInterval: NodeJS.Timeout | null = null;
319
- private heartbeatRunning = false;
320
-
321
- constructor(
322
- _extensionDir: string,
323
- config: SearxngManagerConfig = {},
324
- options?: { docker?: Docker },
325
- ) {
326
- this.config = {
327
- ...DEFAULT_CONFIG,
328
- ...loadConfigFromEnv(),
329
- ...config,
330
- } as Required<SearxngManagerConfig>;
331
- this._extensionDir = _extensionDir;
332
- this._settingsPath = config.settingsPath || path.join(_extensionDir, 'config', 'searxng', 'default-settings.yml');
333
- // Initialize Docker client
334
- try {
335
- this.docker = options?.docker || new Docker(getDockerConnectionCandidates()[0]);
336
- } catch (error) {
337
- logger.warn('[SearXNG Manager] Failed to initialize Docker client:', error);
338
- this.docker = null;
339
- }
340
-
341
- // Initialize StateManager for singleton mode
342
- this.stateManager = new StateManager(config.stateDir);
343
- // Cleanup is orchestrated by the extension lifecycle hook in index.ts, not by process signals here.
344
- }
345
-
346
- /**
347
- * Set extension context for UI notifications
348
- */
349
- setContext(ctx: ExtensionContext): void {
350
- this.ctx = ctx;
351
- }
352
-
353
- /**
354
- * Notify user via UI
355
- */
356
- private notify(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info'): void {
357
- if (this.ctx?.ui) {
358
- // Convert 'success' to 'info' for UI compatibility
359
- const uiType: 'info' | 'warning' | 'error' = type === 'success' ? 'info' : type;
360
- this.ctx.ui.notify(message, uiType);
361
- } else {
362
- logger.log(`[SearXNG Manager ${type.toUpperCase()}] ${message}`);
363
- }
364
- }
365
-
366
- /**
367
- * Get StateManager instance
368
- */
369
- getStateManager(): StateManager | null {
370
- return this.stateManager;
371
- }
372
-
373
- /**
374
- * Check if heartbeat is running
375
- */
376
- isHeartbeatRunning(): boolean {
377
- return this.heartbeatRunning;
378
- }
379
-
380
- /**
381
- * Cleanup stale sessions — removes sessions whose process is dead OR that haven't
382
- * been seen in over 2 hours. Uses StateManager's built-in cleanup which checks
383
- * both the timeout threshold AND process liveness (so crashed processes are
384
- * removed immediately without waiting for the timeout to expire).
385
- */
386
- async cleanupStaleSessions(): Promise<void> {
387
-
388
- try {
389
- const removed = await this.stateManager.cleanupStaleSessions(2 * 60 * 60 * 1000); // 2 hours
390
- if (removed > 0) {
391
- logger.log(`[SearXNG Manager] Cleaned up ${removed} stale session(s)`);
392
- }
393
- } catch (error) {
394
- logger.error('[SearXNG Manager] Error cleaning up stale sessions:', error);
395
- }
396
- }
397
-
398
- /**
399
- * Verify container health
400
- */
401
- async verifyContainerHealthy(): Promise<boolean> {
402
- if (!this.container || this.docker === null) {
403
- return false;
404
- }
405
-
406
- try {
407
- const info: DockerContainerInspectInfo = await this.container.inspect() as DockerContainerInspectInfo;
408
- const portListening = await isPortListening(this.config.port);
409
- return info.State.Running && portListening;
410
- } catch {
411
- return false;
412
- }
413
- }
414
-
415
- /**
416
- * Start heartbeat monitoring
417
- */
418
- startHeartbeat(): void {
419
- if (this.heartbeatRunning) {
420
- logger.warn('[SearXNG Manager] Heartbeat already running');
421
- return;
422
- }
423
-
424
- this.heartbeatRunning = true;
425
- const baseInterval = 30000; // 30 seconds
426
- const jitter = Math.floor(Math.random() * 5000); // 0-5 seconds random
427
- const interval = baseInterval + jitter;
428
-
429
- logger.log(`[SearXNG Manager] Heartbeat interval: ${interval}ms (${baseInterval}ms base + ${jitter}ms jitter)`);
430
-
431
- this.heartbeatInterval = setInterval(() => {
432
- void (async (): Promise<void> => {
433
- try {
434
- // Refresh this session's lastSeen so it isn't pruned by cleanupStaleSessions
435
- if (this.sessionId !== null) {
436
- this.stateManager.updateHeartbeat(this.sessionId).catch(() => {});
437
- }
438
- // Remove sessions whose pi processes have died since the last cycle
439
- await this.cleanupStaleSessions();
440
- await this.verifyContainerHealthy();
441
- } catch (error) {
442
- logger.error('[SearXNG Manager] Heartbeat check failed:', error);
443
- }
444
- })();
445
- }, interval).unref(); // Don't keep event loop alive when nothing else is running
446
-
447
- logger.log('[SearXNG Manager] Heartbeat started');
448
- }
449
-
450
- /**
451
- * Stop heartbeat monitoring
452
- */
453
- stopHeartbeat(): void {
454
- this.heartbeatRunning = false;
455
-
456
- if (this.heartbeatInterval) {
457
- clearInterval(this.heartbeatInterval);
458
- this.heartbeatInterval = null;
459
- }
460
-
461
- logger.log('[SearXNG Manager] Heartbeat stopped');
462
- }
463
-
464
- /**
465
- * Acquire container for session (singleton mode)
466
- */
467
- async acquire(sessionId: string): Promise<void> {
468
-
469
- if (this.containerStartupLock) {
470
- await this.containerStartupLock;
471
- }
472
-
473
- const existingSession = await this.stateManager.getSession(sessionId);
474
- if (existingSession) {
475
- logger.log(`[SearXNG Manager] Session ${sessionId} already has container reference`);
476
- await this.stateManager.updateActivity(sessionId);
477
- this.sessionId = sessionId;
478
- // If this manager instance has no container reference (e.g. after a session
479
- // reconnect where pi reuses the same session ID), attach to the running container
480
- // so ensureReady() and getStatus() work correctly.
481
- if (!this.container) {
482
- try {
483
- const existing = await this.findContainerByName(this.config.containerName);
484
- if (existing) {
485
- const info: DockerContainerInspectInfo = await existing.inspect() as DockerContainerInspectInfo;
486
- this.container = existing;
487
-
488
- const portBindings = info.NetworkSettings.Ports?.['8080/tcp'];
489
- let port = this.config.port;
490
- if (portBindings && portBindings.length > 0) {
491
- const hostPort = portBindings[0]?.HostPort;
492
- if (hostPort) {
493
- port = parseInt(hostPort, 10);
494
- }
495
- }
496
-
497
- this.containerInfo = {
498
- id: existing.id,
499
- name: this.config.containerName,
500
- port,
501
- url: `http://localhost:${port}`,
502
- };
503
- this.config.port = port;
504
- }
505
- } catch (err) {
506
- logger.warn('[SearXNG Manager] Could not attach to existing container on reconnect:', err);
507
- }
508
- }
509
- return;
510
- }
511
-
512
- this.containerStartupLock = (async (): Promise<void> => {
513
- try {
514
- const containerName = this.config.containerName;
515
- logger.log(`[SearXNG Manager] Acquiring container for session ${sessionId}`);
516
-
517
- const existingContainer = await this.findContainerByName(containerName);
518
-
519
- if (existingContainer) {
520
- logger.log(`[SearXNG Manager] Reusing existing singleton container: ${containerName}`);
521
-
522
- const info: DockerContainerInspectInfo = await existingContainer.inspect() as DockerContainerInspectInfo;
523
- this.container = existingContainer;
524
-
525
- let port = this.config.port;
526
- const portBindings = info.NetworkSettings.Ports?.['8080/tcp'];
527
- if (portBindings && portBindings.length > 0) {
528
- const hostPort = portBindings[0]?.HostPort;
529
- if (hostPort) {
530
- port = parseInt(hostPort, 10);
531
- }
532
- }
533
-
534
- this.containerInfo = {
535
- id: existingContainer.id,
536
- name: containerName,
537
- port,
538
- url: `http://localhost:${port}`,
539
- };
540
-
541
- this.config.port = port;
542
-
543
- if (!info.State.Running) {
544
- await this.startContainer(existingContainer);
545
- await this.waitForHealthy();
546
- } else {
547
- const healthy = await this.verifyContainerHealthy();
548
- if (!healthy) {
549
- await this.waitForHealthy();
550
- }
551
- }
552
- } else {
553
- await this.startSingleton();
554
- }
555
-
556
- await this.stateManager.addSession(sessionId, containerName);
557
- this.sessionId = sessionId;
558
- logger.log(`[SearXNG Manager] Session ${sessionId} acquired container reference`);
559
-
560
- } finally {
561
- this.containerStartupLock = null;
562
- }
563
- })();
564
-
565
- await this.containerStartupLock;
566
- }
567
-
568
- /**
569
- * Release container for session (singleton mode)
570
- */
571
- async release(sessionId: string): Promise<void> {
572
-
573
- logger.log(`[SearXNG Manager] Releasing container for session ${sessionId}`);
574
-
575
- await this.stateManager.removeSession(sessionId);
576
-
577
- // Clean up sessions whose processes have died before counting remaining references.
578
- // Without this, stale sessions from crashed/killed terminals accumulate in the state
579
- // file and prevent the container from stopping even when no real sessions remain.
580
- await this.cleanupStaleSessions();
581
-
582
- const remainingSessions = await this.stateManager.getAllSessions();
583
- if (Object.keys(remainingSessions).length === 0) {
584
- logger.log('[SearXNG Manager] No more sessions, stopping singleton container');
585
- await this.stopSingleton();
586
- } else {
587
- logger.log(`[SearXNG Manager] Container still in use by ${Object.keys(remainingSessions).length} session(s)`);
588
- }
589
- }
590
-
591
- /**
592
- * Start singleton container
593
- */
594
- private async startSingleton(): Promise<void> {
595
- const containerName = this.config.containerName;
596
-
597
- logger.log(`[SearXNG Manager] Starting singleton container: ${containerName}`);
598
-
599
- await this.ensureImage();
600
-
601
- try {
602
- const containerResult = await this.createContainer(containerName, this.config.port);
603
- this.container = containerResult.container;
604
- const assignedPort = containerResult.assignedPort;
605
-
606
- await this.startContainer(this.container);
607
-
608
- this.containerInfo = {
609
- id: this.container.id,
610
- name: containerName,
611
- port: assignedPort,
612
- url: `http://localhost:${assignedPort}`,
613
- };
614
-
615
- this.config.port = assignedPort;
616
-
617
- await this.waitForHealthy();
618
-
619
- logger.log(`[SearXNG Manager] Singleton container started on port ${assignedPort}`);
620
- } catch (error) {
621
- logger.error('[SearXNG Manager] Failed to start singleton container:', error);
622
- throw error;
623
- }
624
- }
625
-
626
- /**
627
- * Stop singleton container
628
- */
629
- private async stopSingleton(): Promise<void> {
630
- logger.log('[SearXNG Manager] Stopping singleton container');
631
-
632
- if (this.heartbeatInterval) {
633
- this.stopHeartbeat();
634
- }
635
-
636
- if (this.container) {
637
- try {
638
- await this.stopContainer(this.container);
639
- await this.removeContainer(this.container);
640
- logger.log('[SearXNG Manager] Singleton container stopped');
641
- } catch (error) {
642
- logger.warn('[SearXNG Manager] Error stopping singleton container:', error);
643
- } finally {
644
- this.container = null;
645
- this.containerInfo = null;
646
- }
647
- }
648
- }
649
-
650
- /**
651
- * Check if Docker is available
652
- */
653
- private async checkDocker(): Promise<boolean> {
654
- if (!this.docker) {
655
- return false;
656
- }
657
-
658
- try {
659
- await this.docker.ping();
660
- return true;
661
- } catch {
662
- return false;
663
- }
664
- }
665
-
666
- /**
667
- * Get full image name with tag
668
- */
669
- private getFullImageName(): string {
670
- return `${this.config.imageName}:${this.config.imageTag}`;
671
- }
672
-
673
- /**
674
- * Check if Docker image exists locally
675
- */
676
- private async imageExists(): Promise<boolean> {
677
- if (!this.docker) {
678
- return false;
679
- }
680
-
681
- try {
682
- const fullImage = this.getFullImageName();
683
- const image = this.docker.getImage(fullImage);
684
- await image.inspect();
685
- return true;
686
- } catch {
687
- // Image doesn't exist
688
- return false;
689
- }
690
- }
691
-
692
- /**
693
- * Pull the latest Docker image
694
- */
695
- private async pullLatestImage(): Promise<void> {
696
- if (this.docker === null) {
697
- throw new Error('Docker not available.');
698
- }
699
-
700
- const fullImage = this.getFullImageName();
701
-
702
- this.notify(`Updating SearXNG image: ${fullImage}...`, 'info');
703
- this.notify('This may take 1-2 minutes on first run.', 'warning');
704
-
705
- try {
706
- const docker = this.docker;
707
- await new Promise<void>((resolve, reject) => {
708
- void docker.pull(fullImage, (err: Error | null, stream: NodeJS.ReadableStream) => {
709
- if (err) {
710
- reject(err);
711
- return;
712
- }
713
-
714
- docker.modem.followProgress(stream, (err: Error | null) => {
715
- if (err) {
716
- reject(err);
717
- } else {
718
- resolve();
719
- }
720
- }, (event: Record<string, unknown>) => {
721
- // Optional: log progress events
722
- if (event['status'] === 'Pulling fs layer' || event['status'] === 'Downloading') {
723
- // Could show progress here
724
- }
725
- });
726
- });
727
- });
728
-
729
- this.notify('SearXNG image updated successfully.', 'success');
730
- } catch (error: unknown) {
731
- const errMsg = error instanceof Error ? error.message : String(error);
732
- throw new Error(`Failed to pull image: ${errMsg}`, { cause: error });
733
- }
734
- }
735
-
736
- /**
737
- * Prune old SearXNG images (dangling and non-latest tags)
738
- */
739
- private async pruneOldImages(): Promise<void> {
740
- if (!this.docker) {
741
- return;
742
- }
743
-
744
- if (!this.config.pruneOldImages) {
745
- return;
746
- }
747
-
748
- try {
749
- this.notify('Cleaning up old SearXNG images...', 'info');
750
-
751
- // Get all images for the repository
752
- const images: DockerImageInfo[] = await this.docker.listImages({ filters: { reference: [this.config.imageName] } });
753
-
754
- const currentImage = this.getFullImageName();
755
-
756
- for (const image of images) {
757
- const repoTags = image.RepoTags;
758
- if (repoTags && repoTags.length > 0) {
759
- for (const tag of repoTags) {
760
- // Skip the current image (latest)
761
- if (tag === currentImage) {
762
- continue;
763
- }
764
-
765
- // Remove old images
766
- const img = this.docker.getImage(tag);
767
- try {
768
- await img.remove({ force: false });
769
- this.notify(`Removed old image: ${tag}`, 'info');
770
- } catch (error) {
771
- // Image might be in use, that's okay
772
- logger.warn('[SearXNG Manager] Could not remove image:', tag, error);
773
- }
774
- }
775
- }
776
- }
777
-
778
- // Also prune dangling images
779
- await this.docker.pruneImages({ filters: { dangling: ['true'] } });
780
-
781
- this.notify('Image cleanup complete.', 'success');
782
- } catch (error) {
783
- logger.warn('[SearXNG Manager] Error pruning old images:', error);
784
- // Don't fail the whole process if pruning fails
785
- }
786
- }
787
-
788
- /**
789
- * Ensure image is available and up-to-date
790
- */
791
- private async ensureImage(): Promise<void> {
792
- const imageExists = await this.imageExists();
793
-
794
- if (!imageExists) {
795
- // Image doesn't exist, pull it
796
- await this.pullLatestImage();
797
- } else {
798
- // Image exists, prune old ones and optionally pull latest
799
- await this.pruneOldImages();
800
-
801
- // Optionally: always pull latest to stay up-to-date
802
- // Uncomment the next line to always update on start:
803
- // await this.pullLatestImage();
804
- }
805
- }
806
-
807
- /**
808
- * Find container by name
809
- */
810
- private async findContainerByName(name: string): Promise<Docker.Container | null> {
811
- if (!this.docker) {
812
- return null;
813
- }
814
-
815
- try {
816
- const containers: DockerContainerListInfo[] = await this.docker.listContainers({ all: true });
817
-
818
- for (const c of containers) {
819
- if (c.Names.some((n: string) => n === `/${name}`)) {
820
- return this.docker.getContainer(c.Id);
821
- }
822
- }
823
-
824
- return null;
825
- } catch {
826
- return null;
827
- }
828
- }
829
-
830
- /**
831
- * Create a new container with port auto-assignment
832
- */
833
- private async createContainer(
834
- name: string,
835
- preferredPort: number | null,
836
- ): Promise<{ container: Docker.Container; assignedPort: number }> {
837
- if (!this.docker) {
838
- throw new Error('Docker not available.');
839
- }
840
-
841
- const fullImage = this.getFullImageName();
842
-
843
- // Mount both settings.yml and limiter.toml for complete bot detection disable
844
- const settingsPath = this._settingsPath;
845
- const limiterConfigPath = path.join(this._extensionDir, 'config', 'searxng', 'limiter.toml');
846
-
847
- // Verify configuration files exist
848
- if (!fs.existsSync(settingsPath)) {
849
- throw new Error(`Settings file not found: ${settingsPath}.`);
850
- }
851
- if (!fs.existsSync(limiterConfigPath)) {
852
- throw new Error(`Limiter config not found: ${limiterConfigPath}. Please ensure limiter.toml exists.`);
853
- }
854
-
855
- // Try preferred port first, or use auto-assignment (empty string)
856
- const hostPort = preferredPort ? preferredPort.toString() : '';
857
-
858
- const containerConfig: DockerContainerCreateOptions = {
859
- name,
860
- Image: fullImage,
861
- ExposedPorts: { '8080/tcp': {} },
862
- HostConfig: {
863
- PortBindings: {
864
- '8080/tcp': [{ HostPort: hostPort }],
865
- },
866
- Binds: [
867
- `${toDockerBindPath(settingsPath)}:/etc/searxng/settings.yml:ro`,
868
- `${toDockerBindPath(limiterConfigPath)}:/etc/searxng/limiter.toml:ro`,
869
- ],
870
- AutoRemove: false,
871
- ExtraHosts: getDockerHostGatewayExtraHosts(),
872
- },
873
- Env: [
874
- // No SEARXNG_LIMITER or SEARXNG_BIND_ADDRESS - use mounted config files only
875
- ],
876
- Labels: {
877
- 'managed-by': 'pi-research',
878
- 'mode': 'singleton',
879
- },
880
- };
881
-
882
- const container = await this.docker.createContainer(containerConfig);
883
-
884
- // Query the actual assigned port (important for auto-assignment)
885
- const info: DockerContainerInspectInfo = await container.inspect() as DockerContainerInspectInfo;
886
- const portBindings = info.NetworkSettings.Ports?.['8080/tcp'];
887
- const assignedPort = (portBindings && portBindings.length > 0 && portBindings[0]?.HostPort)
888
- ? parseInt(portBindings[0].HostPort, 10)
889
- : (preferredPort ?? 0);
890
-
891
- logger.log(`[SearXNG Manager] Container created with port ${assignedPort}`);
892
-
893
- return { container, assignedPort };
894
- }
895
-
896
- /**
897
- * Start a container
898
- */
899
- private async startContainer(container: Docker.Container): Promise<void> {
900
- await container.start();
901
- }
902
-
903
- /**
904
- * Stop a container
905
- */
906
- private async stopContainer(container: Docker.Container): Promise<void> {
907
- try {
908
- await container.stop({ t: 10 });
909
- } catch (error: unknown) {
910
- // Container might already be stopped (error 304)
911
- const errorStr = String(error);
912
- if (errorStr.includes('is not running') ||
913
- errorStr.includes('container already stopped') ||
914
- errorStr.includes('304')) {
915
- // Already stopped, that's fine
916
- return;
917
- }
918
- logger.warn('[SearXNG Manager] Error stopping container:', error);
919
- }
920
- }
921
-
922
- /**
923
- * Remove a container
924
- */
925
- private async removeContainer(container: Docker.Container): Promise<void> {
926
- await Promise.race([
927
- container.remove({ v: true, force: true }),
928
- new Promise<void>((_, reject) => {
929
- setTimeout(() => {
930
- reject(new Error('container.remove timed out after 15s'));
931
- }, 15000);
932
- }),
933
- ]).catch((err: unknown) => {
934
- if (!String(err).includes('No such container')) {
935
- logger.warn('[SearXNG Manager] Error removing container:', err);
936
- }
937
- });
938
- }
939
-
940
- /**
941
- * Wait for container to be healthy
942
- */
943
- private async waitForHealthy(): Promise<void> {
944
- if (!this.container) {
945
- throw new Error('No container to wait for.');
946
- }
947
-
948
- const startTime = Date.now();
949
- const timeout = this.config.healthTimeout;
950
- const checkInterval = 3000; // Check every 3 seconds
951
- const STARTUP_DELAY = 5000; // Wait 5 seconds for initial startup
952
-
953
- this.notify('Starting SearXNG container and waiting for it to be ready...', 'info');
954
- this.notify('If this is your first run, it may take 1-2 minutes to download the image.', 'warning');
955
- this.notify('Subsequent runs will be much faster.', 'info');
956
-
957
- // Wait for container to start (initial delay)
958
- await sleep(STARTUP_DELAY);
959
-
960
- let consecutiveHealthyChecks = 0;
961
- const HEALTHY_CHECKS_NEEDED = 2; // Need 2 consecutive healthy checks
962
- let lastElapsedTime = '';
963
-
964
- while (Date.now() - startTime < timeout) {
965
- try {
966
- // Check if container still exists and get its state in one call
967
- let info: DockerContainerInspectInfo;
968
- try {
969
- info = await this.container.inspect() as DockerContainerInspectInfo;
970
- } catch (error: unknown) {
971
- const err = error as { statusCode?: number };
972
- if (err.statusCode === 404) {
973
- // Container was removed, recreate it
974
- this.notify('Container was removed, recreating...', 'warning');
975
- const goneError = new Error('CONTAINER_GONE');
976
- goneError.cause = error;
977
- throw goneError;
978
- }
979
- throw error;
980
- }
981
-
982
- // Check if container is still running
983
- if (!info.State.Running) {
984
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
985
-
986
- // Container has exited, get logs to understand why
987
- try {
988
- const logs = await this.container.logs({ stdout: true, stderr: true, tail: 50 });
989
- const logsStr = logs.toString();
990
-
991
- this.notify(`Container exited with status: ${info.State.Status} (${elapsed}s elapsed)`, 'error');
992
- logger.error('[SearXNG Manager] Container logs:', logsStr);
993
-
994
- // Check for common error patterns
995
- if (logsStr.includes('bind') && (logsStr.includes('address already in use') || logsStr.includes('already allocated'))) {
996
- throw new Error(`Failed to start container: Port ${this.config.port} is already in use. Check if another SearXNG container or service is using this port.`, { cause: logsStr });
997
- }
998
-
999
- // Generic error with logs
1000
- throw new Error(`Container exited unexpectedly. Last 50 lines of logs:\n${logsStr}`, { cause: logsStr });
1001
- } catch (error: unknown) {
1002
- if (error instanceof Error) {
1003
- throw error; // Re-throw our custom error
1004
- }
1005
- // If log retrieval failed, throw a generic error
1006
- throw new Error(`Container exited with status: ${info.State.Status}. Unable to retrieve logs.`, { cause: error });
1007
- }
1008
- }
1009
-
1010
- // Enhanced health check: container running AND port is listening
1011
- // SearXNG bot detection blocks HTTP requests, so we use TCP check instead
1012
- const portListening = await isPortListening(this.config.port);
1013
- if (portListening) {
1014
- consecutiveHealthyChecks++;
1015
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
1016
- if (consecutiveHealthyChecks >= HEALTHY_CHECKS_NEEDED) {
1017
- this.notify(`SearXNG container is ready! (${elapsed}s elapsed)`, 'success');
1018
- return;
1019
- } else if (lastElapsedTime !== elapsed) {
1020
- this.notify(`SearXNG container starting up... (${elapsed}s elapsed)`, 'info');
1021
- lastElapsedTime = elapsed;
1022
- }
1023
- } else {
1024
- // Container is running but port not listening yet
1025
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
1026
- if (lastElapsedTime !== elapsed) {
1027
- this.notify(`SearXNG container running, waiting for port ${this.config.port} to be ready... (${elapsed}s elapsed)`, 'info');
1028
- lastElapsedTime = elapsed;
1029
- }
1030
- }
1031
-
1032
- await sleep(checkInterval);
1033
- } catch (error: unknown) {
1034
- // If container is gone, rethrow to trigger recreation
1035
- if (error instanceof Error && error['message'] === 'CONTAINER_GONE') {
1036
- throw error;
1037
- }
1038
- // Log other errors but continue
1039
- logger.error('[SearXNG Manager] Health check error:', error);
1040
- await sleep(checkInterval);
1041
- }
1042
- }
1043
-
1044
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
1045
- const containerId = this.container.id;
1046
- throw new Error(`Container did not become healthy within ${elapsed}s. Check Docker logs with: docker logs ${containerId}`);
1047
- }
1048
-
1049
- /**
1050
- * Start SearXNG for a session
1051
- */
1052
- async start(sessionId: string): Promise<void> {
1053
- this.sessionId = sessionId;
1054
-
1055
- const dockerAvailable = await this.checkDocker();
1056
-
1057
- if (!dockerAvailable) {
1058
- this.notify('Docker is not available. SearXNG search functionality will not work.', 'error');
1059
- throw new Error('Docker not available');
1060
- }
1061
-
1062
- logger.log('[SearXNG Manager] Starting in singleton mode');
1063
-
1064
- await this.cleanupStaleSessions();
1065
- await this.acquire(sessionId);
1066
- this.startHeartbeat();
1067
- }
1068
-
1069
- /**
1070
- * Stop SearXNG container
1071
- */
1072
- async stop(): Promise<void> {
1073
- if (this.sessionId) {
1074
- logger.log(`[SearXNG Manager] Releasing container for session ${this.sessionId}`);
1075
- await this.release(this.sessionId);
1076
- this.sessionId = null;
1077
- this.starting = null;
1078
- }
1079
- }
1080
-
1081
- /**
1082
- * Get current status
1083
- */
1084
- async getStatus(): Promise<SearxngStatus> {
1085
- const dockerAvailable = await this.checkDocker();
1086
-
1087
- if (!dockerAvailable) {
1088
- return {
1089
- dockerAvailable: false,
1090
- healthy: false,
1091
- };
1092
- }
1093
-
1094
- if (this.container && this.containerInfo) {
1095
- try {
1096
- const info: DockerContainerInspectInfo = await this.container.inspect() as DockerContainerInspectInfo;
1097
-
1098
- // Enhanced health check: container running AND port is listening
1099
- // SearXNG bot detection blocks HTTP requests, so we use TCP check instead
1100
- const portListening = await isPortListening(this.config.port);
1101
- const healthy = info.State.Running && portListening;
1102
-
1103
- return {
1104
- containerId: this.container.id,
1105
- containerName: this.containerInfo.name,
1106
- port: this.containerInfo.port,
1107
- url: this.containerInfo.url,
1108
- healthy,
1109
- dockerAvailable: true,
1110
- };
1111
- } catch (error) {
1112
- return {
1113
- dockerAvailable: true,
1114
- healthy: false,
1115
- error: error instanceof Error ? error.message : String(error),
1116
- };
1117
- }
1118
- }
1119
-
1120
- return {
1121
- dockerAvailable: true,
1122
- healthy: false,
1123
- };
1124
- }
1125
-
1126
- /**
1127
- * Get SearXNG URL
1128
- */
1129
- getSearxngUrl(): string {
1130
- if (this.containerInfo) {
1131
- return this.containerInfo.url;
1132
- }
1133
-
1134
- throw new Error('SearXNG container not running.');
1135
- }
1136
-
1137
- /**
1138
- * Ensure container is ready before use
1139
- */
1140
- async ensureReady(): Promise<void> {
1141
- if (!this.container || !this.containerInfo) {
1142
- throw new Error('SearXNG container not initialized. Call acquire() first.');
1143
- }
1144
-
1145
- // If a start is already in progress, wait for it
1146
- if (this.starting) {
1147
- await this.starting;
1148
- return;
1149
- }
1150
-
1151
- // Check if container is healthy
1152
- const status = await this.getStatus();
1153
- if (!status.healthy) {
1154
- // Directly stop/remove/restart the container rather than going through start() →
1155
- // acquire(). acquire() early-returns when the session already exists in state, so
1156
- // calling start() here would leave the unhealthy container in place.
1157
- this.starting = (async (): Promise<void> => {
1158
- try {
1159
- if (this.container) {
1160
- try {
1161
- await this.stopContainer(this.container);
1162
- } catch { /* ignore — may already be stopped */ }
1163
- try {
1164
- await this.removeContainer(this.container);
1165
- } catch { /* ignore — may already be gone */ }
1166
- this.container = null;
1167
- this.containerInfo = null;
1168
- }
1169
- await this.startSingleton();
1170
- } finally {
1171
- this.starting = null;
1172
- }
1173
- })();
1174
-
1175
- try {
1176
- await this.starting;
1177
- } catch (error: unknown) {
1178
- const errMsg = error instanceof Error ? error.message : String(error);
1179
- throw new Error(`SearXNG container is not healthy and restart failed: ${errMsg}`, { cause: error });
1180
- }
1181
- }
1182
- }
1183
- }