@lincoln504/pi-research 0.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/config/searxng/default-settings.yml +189 -0
  4. package/config/searxng/limiter.toml +34 -0
  5. package/config/tooling/eslint.config.js +73 -0
  6. package/config/tooling/vitest.config.integration.ts +17 -0
  7. package/config/tooling/vitest.config.ts +40 -0
  8. package/config/tooling/vitest.config.unit.ts +12 -0
  9. package/docs/ARCHITECTURE.md +109 -0
  10. package/docs/assets/README-banner.jpg +0 -0
  11. package/package.json +92 -0
  12. package/scripts/setup.js +174 -0
  13. package/src/config.ts +206 -0
  14. package/src/constants.ts +148 -0
  15. package/src/healthcheck/index.ts +309 -0
  16. package/src/index.ts +71 -0
  17. package/src/infrastructure/searxng-lifecycle.ts +577 -0
  18. package/src/infrastructure/searxng-manager.ts +1173 -0
  19. package/src/infrastructure/state-manager.ts +918 -0
  20. package/src/logger.ts +246 -0
  21. package/src/orchestration/deep-research-orchestrator.ts +848 -0
  22. package/src/orchestration/deep-research-reducer.ts +129 -0
  23. package/src/orchestration/deep-research-types.ts +54 -0
  24. package/src/orchestration/id-utils.ts +63 -0
  25. package/src/orchestration/researcher.ts +104 -0
  26. package/src/orchestration/session-context.ts +43 -0
  27. package/src/prompts/research-tool-usage.md +45 -0
  28. package/src/prompts/researcher.md +166 -0
  29. package/src/prompts/system-coordinator.md +24 -0
  30. package/src/prompts/system-lead-evaluator.md +225 -0
  31. package/src/security/cisa-kev.ts +242 -0
  32. package/src/security/github-advisories.ts +607 -0
  33. package/src/security/index.ts +406 -0
  34. package/src/security/nvd.ts +532 -0
  35. package/src/security/osv.ts +487 -0
  36. package/src/security/types.ts +138 -0
  37. package/src/stackexchange/cache.ts +111 -0
  38. package/src/stackexchange/index.ts +352 -0
  39. package/src/stackexchange/output/compact.ts +64 -0
  40. package/src/stackexchange/output/index.ts +7 -0
  41. package/src/stackexchange/output/json.ts +21 -0
  42. package/src/stackexchange/output/table.ts +123 -0
  43. package/src/stackexchange/queries.ts +168 -0
  44. package/src/stackexchange/rest-client.ts +127 -0
  45. package/src/stackexchange/types.ts +129 -0
  46. package/src/tool.ts +430 -0
  47. package/src/tools/grep.ts +335 -0
  48. package/src/tools/index.ts +77 -0
  49. package/src/tools/scrape.ts +451 -0
  50. package/src/tools/search.ts +188 -0
  51. package/src/tools/security.ts +292 -0
  52. package/src/tools/stackexchange.ts +101 -0
  53. package/src/tui/research-panel.ts +576 -0
  54. package/src/tui/searxng-status.ts +80 -0
  55. package/src/types/extension-context.ts +60 -0
  56. package/src/types/llm.ts +111 -0
  57. package/src/utils/inject-date.ts +29 -0
  58. package/src/utils/input-validation.ts +103 -0
  59. package/src/utils/json-utils.ts +262 -0
  60. package/src/utils/make-resource-loader.ts +43 -0
  61. package/src/utils/research-error.ts +205 -0
  62. package/src/utils/research-export.ts +199 -0
  63. package/src/utils/searxng-config.ts +94 -0
  64. package/src/utils/session-state.ts +295 -0
  65. package/src/utils/shared-links.ts +123 -0
  66. package/src/utils/shutdown-manager.ts +54 -0
  67. package/src/utils/text-utils.ts +83 -0
  68. package/src/utils/tool-usage-tracker.ts +135 -0
  69. package/src/web-research/dependencies.ts +20 -0
  70. package/src/web-research/retry-utils.ts +232 -0
  71. package/src/web-research/scrapers.ts +935 -0
  72. package/src/web-research/search.ts +235 -0
  73. package/src/web-research/types.ts +47 -0
  74. package/src/web-research/utils.ts +158 -0
  75. package/tsconfig.json +40 -0
@@ -0,0 +1,918 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as crypto from 'crypto';
4
+ import * as os from 'os';
5
+ import { logger } from '../logger.ts';
6
+
7
+ /**
8
+ * State metrics interface
9
+ */
10
+ export interface StateMetrics {
11
+ totalSessions: number;
12
+ activeSessions: number;
13
+ oldestSession: number | null;
14
+ newestSession: number | null;
15
+ containerUptime: number | null;
16
+ lastHeartbeatAge: number | null;
17
+ }
18
+
19
+ /**
20
+ * Session information interface
21
+ */
22
+ export interface SessionInfo {
23
+ pid: number;
24
+ processStartTime?: number; // For PID reuse detection
25
+ lastSeen: number;
26
+ connectedAt: number;
27
+ }
28
+
29
+ /**
30
+ * Main state structure interface
31
+ */
32
+ export interface SingletonState {
33
+ version: 1;
34
+ containerId: string;
35
+ containerName: string;
36
+ port: number;
37
+ sessions: { [sessionId: string]: SessionInfo };
38
+ lastUpdated: number;
39
+ }
40
+
41
+ /**
42
+ * Legacy session interface for backward compatibility
43
+ */
44
+ export interface LegacySessionInfo {
45
+ lastSeen: number;
46
+ }
47
+
48
+ /**
49
+ * Legacy state interface for backward compatibility
50
+ */
51
+ export interface LegacyState {
52
+ sessions: { [sessionId: string]: LegacySessionInfo };
53
+ containerExists: boolean;
54
+ containerPort: number;
55
+ }
56
+
57
+ /**
58
+ * Type guard to check if a value is a SingletonState
59
+ */
60
+ function isSingletonState(value: unknown): value is SingletonState {
61
+ if (!value || typeof value !== 'object') {
62
+ return false;
63
+ }
64
+
65
+ const state = value as Partial<SingletonState>;
66
+
67
+ return (
68
+ state.version === 1 &&
69
+ typeof state.containerId === 'string' &&
70
+ typeof state.containerName === 'string' &&
71
+ typeof state.port === 'number' &&
72
+ typeof state.sessions === 'object' &&
73
+ typeof state.lastUpdated === 'number'
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Type guard to check if a value is a SessionInfo
79
+ */
80
+ function isSessionInfo(value: unknown): value is SessionInfo {
81
+ if (!value || typeof value !== 'object') {
82
+ return false;
83
+ }
84
+
85
+ const session = value as Partial<SessionInfo>;
86
+
87
+ return (
88
+ typeof session.pid === 'number' &&
89
+ typeof session.lastSeen === 'number' &&
90
+ typeof session.connectedAt === 'number' &&
91
+ (session.processStartTime === undefined || typeof session.processStartTime === 'number')
92
+ );
93
+ }
94
+
95
+ /**
96
+ * StateManager class for managing singleton state with file-based storage,
97
+ * file locking, backup system, and corruption recovery.
98
+ */
99
+ export class StateManager {
100
+ // Path configuration
101
+ private readonly stateFilePath: string;
102
+ private readonly lockDirPath: string;
103
+ private readonly backupDirPath: string;
104
+ private readonly lockFilePath: string;
105
+
106
+ // Lock configuration
107
+ private readonly lockTimeout: number = 10000; // 10 seconds
108
+ private readonly lockRetries: number = 100; // 10 seconds with 100ms delay
109
+ private readonly lockRetryDelay: number = 100; // 100ms between retries
110
+ private readonly lockStaleThreshold: number = 30000; // 30 seconds
111
+
112
+ // Backup configuration
113
+ private readonly maxBackups: number = 5;
114
+
115
+ // Lock tracking
116
+ private lockHandle: fs.FileHandle | null = null;
117
+
118
+ constructor(stateDir?: string) {
119
+ if (!stateDir) {
120
+ const homeDir = os.homedir();
121
+ stateDir = path.join(homeDir, '.pi', 'state');
122
+ }
123
+
124
+ this.stateFilePath = path.join(stateDir, 'searxng-singleton.json');
125
+ this.lockDirPath = path.join(stateDir, '.locks');
126
+ this.backupDirPath = path.join(stateDir, 'backups');
127
+ this.lockFilePath = path.join(this.lockDirPath, 'searxng-singleton.lock');
128
+
129
+ // Clean up any stale locks on initialization (fire and forget)
130
+ this.cleanupStaleLocksOnStartup().catch((error: unknown) => {
131
+ logger.warn('[StateManager] Failed to cleanup stale locks on startup:', error instanceof Error ? error.message : String(error));
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Clean up any stale lock files on initialization.
137
+ * This handles cases where locks weren't released due to crashes.
138
+ */
139
+ private async cleanupStaleLocksOnStartup(): Promise<void> {
140
+ await this.ensureDirectories();
141
+
142
+ try {
143
+ const stats = await fs.stat(this.lockFilePath);
144
+ const lockAge = Date.now() - stats.mtimeMs;
145
+
146
+ // Clean up locks older than stale threshold (30 seconds)
147
+ if (lockAge > this.lockStaleThreshold) {
148
+ logger.log(`[StateManager] Cleaning up stale lock file (${Math.round(lockAge / 1000)}s old)`);
149
+ await fs.unlink(this.lockFilePath);
150
+ logger.log('[StateManager] Stale lock removed');
151
+ }
152
+ } catch (error: unknown) {
153
+ // ENOENT is expected (no lock file exists)
154
+ if (error instanceof Error && 'code' in error) {
155
+ const errnoError = error as NodeJS.ErrnoException;
156
+ if (errnoError.code !== 'ENOENT') {
157
+ logger.warn(`[StateManager] Could not check lock file: ${errnoError.message}`);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Initialize directories with proper permissions
165
+ */
166
+ private async ensureDirectories(): Promise<void> {
167
+ const dirs = [
168
+ path.dirname(this.stateFilePath),
169
+ this.lockDirPath,
170
+ this.backupDirPath,
171
+ ];
172
+
173
+ for (const dir of dirs) {
174
+ try {
175
+ await fs.mkdir(dir, { mode: 0o700, recursive: true });
176
+ } catch (error: unknown) {
177
+ if (error instanceof Error && 'code' in error) {
178
+ const errnoError = error as NodeJS.ErrnoException;
179
+ if (errnoError.code !== 'EEXIST') {
180
+ throw new Error(`Failed to create directory ${dir}: ${errnoError.message}`, { cause: error });
181
+ }
182
+ } else {
183
+ throw new Error(`Failed to create directory ${dir}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Get the default state object
191
+ */
192
+ private getDefaultState(): SingletonState {
193
+ return {
194
+ version: 1,
195
+ containerId: '',
196
+ containerName: '',
197
+ port: 0,
198
+ sessions: {},
199
+ lastUpdated: Date.now(),
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Read the state from the file system with lock protection
205
+ * @returns The current state object
206
+ * @throws Error if unable to read, parse, or acquire lock
207
+ */
208
+ public async readState(): Promise<SingletonState> {
209
+ await this.ensureDirectories();
210
+ return this.withLock(() => this._readState());
211
+ }
212
+
213
+ /**
214
+ * Internal read without lock acquisition (caller must hold lock)
215
+ */
216
+ private async _readState(): Promise<SingletonState> {
217
+ try {
218
+ const content = await fs.readFile(this.stateFilePath, 'utf-8');
219
+ const state = JSON.parse(content) as unknown;
220
+
221
+ // Validate state structure and version
222
+ this.validateState(state);
223
+
224
+ return state as SingletonState;
225
+ } catch (error: unknown) {
226
+ if (error instanceof Error && 'code' in error) {
227
+ const errnoError = error as NodeJS.ErrnoException;
228
+ if (errnoError.code === 'ENOENT') {
229
+ // File doesn't exist, return default state
230
+ return this.getDefaultState();
231
+ }
232
+ }
233
+
234
+ // Check for corruption
235
+ if (error instanceof SyntaxError || (error instanceof Error && error.message.includes('parse'))) {
236
+ logger.error('[StateManager] State file corrupted, attempting recovery...');
237
+ // Recover directly — we already hold the lock
238
+ await this.recoverFromCorruptionDirect();
239
+ // Re-read directly
240
+ try {
241
+ const recovered = await fs.readFile(this.stateFilePath, 'utf-8');
242
+ const recoveredState = JSON.parse(recovered) as unknown;
243
+ this.validateState(recoveredState);
244
+ return recoveredState as SingletonState;
245
+ } catch {
246
+ // Recovery produced an unreadable file — return safe default.
247
+ return this.getDefaultState();
248
+ }
249
+ }
250
+
251
+ throw new Error(`Failed to read state: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Write the state to the file system atomically with backup creation
257
+ * @param state The state object to write
258
+ * @throws Error if unable to write or backup
259
+ */
260
+ public async writeState(state: SingletonState): Promise<void> {
261
+ await this.ensureDirectories();
262
+ return this.withLock(() => this._writeState(state));
263
+ }
264
+
265
+ /**
266
+ * Internal write without lock acquisition (caller must hold lock)
267
+ */
268
+ private async _writeState(state: SingletonState): Promise<void> {
269
+ this.validateState(state);
270
+
271
+ // Update lastUpdated timestamp
272
+ state.lastUpdated = Date.now();
273
+
274
+ try {
275
+ // Create backup before writing
276
+ await this.createBackup();
277
+
278
+ // Create temporary file with UUID for atomic write
279
+ const tempFileName = `searxng-singleton-${crypto.randomBytes(16).toString('hex')}.tmp`;
280
+ const tempFilePath = path.join(path.dirname(this.stateFilePath), tempFileName);
281
+
282
+ // Write to temporary file
283
+ const content = JSON.stringify(state, null, 2);
284
+ await fs.writeFile(tempFilePath, content, 'utf-8');
285
+
286
+ // Atomic rename to target file
287
+ await fs.rename(tempFilePath, this.stateFilePath);
288
+
289
+ // Cleanup old backups
290
+ await this.cleanupOldBackups();
291
+ } catch (error: unknown) {
292
+ throw new Error(`Failed to write state: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Update state atomically using an updater function (read-modify-write pattern)
298
+ * @param updater Function that receives current state and returns updated state
299
+ * @throws Error if unable to read, update, or write state
300
+ */
301
+ public async updateState(updater: (state: SingletonState) => SingletonState | Promise<SingletonState>): Promise<void> {
302
+ await this.ensureDirectories();
303
+ return this.withLock(async () => {
304
+ const currentState = await this._readState();
305
+ const newState = await updater(currentState);
306
+ await this._writeState(newState);
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Add a new session to the state
312
+ * @param sessionId The session ID to add
313
+ * @param param PID (number) or container name (string)
314
+ * @throws Error if unable to update state or read process start time
315
+ */
316
+ public async addSession(sessionId: string, param: number | string): Promise<void> {
317
+ let pid: number;
318
+ if (typeof param === 'number') {
319
+ pid = param;
320
+ } else {
321
+ // param is containerName string, use current process pid
322
+ pid = process.pid;
323
+ }
324
+
325
+ await this.updateState(async (state): Promise<SingletonState> => {
326
+ const processStartTime = await this.getProcessStartTime(pid);
327
+
328
+ state.sessions[sessionId] = {
329
+ pid,
330
+ processStartTime,
331
+ lastSeen: Date.now(),
332
+ connectedAt: Date.now(),
333
+ };
334
+
335
+ return state;
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Remove a session from the state
341
+ * @param sessionId The session ID to remove
342
+ */
343
+ public async removeSession(sessionId: string): Promise<void> {
344
+ await this.updateState((state): SingletonState => {
345
+ if (state.sessions[sessionId] !== undefined) {
346
+ delete state.sessions[sessionId];
347
+ }
348
+ return state;
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Update the heartbeat timestamp for a session
354
+ * @param sessionId The session ID to update
355
+ */
356
+ public async updateHeartbeat(sessionId: string): Promise<void> {
357
+ await this.updateState((state): SingletonState => {
358
+ const session = state.sessions[sessionId];
359
+ if (session !== undefined) {
360
+ session.lastSeen = Date.now();
361
+ }
362
+ return state;
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Clean up stale sessions based on timeout and process liveness
368
+ * @param timeoutMs Timeout in milliseconds for session staleness
369
+ * @returns Number of sessions removed
370
+ */
371
+ public async cleanupStaleSessions(timeoutMs: number): Promise<number> {
372
+ const now = Date.now();
373
+ const sessionsToRemove: string[] = [];
374
+
375
+ const state = await this.readState();
376
+
377
+ for (const [sessionId, sessionInfo] of Object.entries(state.sessions)) {
378
+ // Primary check: lastSeen timeout
379
+ const lastSeenAge = now - sessionInfo.lastSeen;
380
+
381
+ if (lastSeenAge > timeoutMs) {
382
+ sessionsToRemove.push(sessionId);
383
+ continue;
384
+ }
385
+
386
+ // Secondary check: is process still alive?
387
+ const isAlive = await this.isProcessAlive(sessionInfo.pid, sessionInfo.processStartTime);
388
+
389
+ if (!isAlive) {
390
+ sessionsToRemove.push(sessionId);
391
+ }
392
+ }
393
+
394
+ // Remove stale sessions
395
+ if (sessionsToRemove.length > 0) {
396
+ await this.updateState((state): SingletonState => {
397
+ for (const sessionId of sessionsToRemove) {
398
+ delete state.sessions[sessionId];
399
+ }
400
+ return state;
401
+ });
402
+ }
403
+
404
+ return sessionsToRemove.length;
405
+ }
406
+
407
+ /**
408
+ * Get metrics about the current state
409
+ * @returns StateMetrics object with various statistics
410
+ */
411
+ public async getMetrics(): Promise<StateMetrics> {
412
+ const state = await this.readState();
413
+ const now = Date.now();
414
+
415
+ const sessionEntries = Object.entries(state.sessions);
416
+ const totalSessions = sessionEntries.length;
417
+
418
+ let activeSessions = 0;
419
+ let oldestSession: number | null = null;
420
+ let newestSession: number | null = null;
421
+ let lastHeartbeatAge: number | null = null;
422
+
423
+ for (const [, sessionInfo] of sessionEntries) {
424
+ // Check if session is active (lastSeen within last 5 minutes)
425
+ if (now - sessionInfo.lastSeen < 300000) {
426
+ activeSessions++;
427
+ }
428
+
429
+ // Track oldest and newest connection times
430
+ if (oldestSession === null || sessionInfo.connectedAt < oldestSession) {
431
+ oldestSession = sessionInfo.connectedAt;
432
+ }
433
+
434
+ if (newestSession === null || sessionInfo.connectedAt > newestSession) {
435
+ newestSession = sessionInfo.connectedAt;
436
+ }
437
+
438
+ // Track last heartbeat age
439
+ const heartbeatAge = now - sessionInfo.lastSeen;
440
+ if (lastHeartbeatAge === null || heartbeatAge > lastHeartbeatAge) {
441
+ lastHeartbeatAge = heartbeatAge;
442
+ }
443
+ }
444
+
445
+ // Calculate container uptime if container exists
446
+ let containerUptime: number | null = null;
447
+ if (state.containerId !== '') {
448
+ // Use lastUpdated as approximation for container uptime
449
+ containerUptime = now - state.lastUpdated;
450
+ }
451
+
452
+ return {
453
+ totalSessions,
454
+ activeSessions,
455
+ oldestSession,
456
+ newestSession,
457
+ containerUptime,
458
+ lastHeartbeatAge,
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Check if a process is alive (optionally with start time verification)
464
+ * @param pid Process ID to check
465
+ * @param expectedStartTime Optional expected process start time (for PID reuse detection)
466
+ * @returns true if process is alive and matches expected start time
467
+ */
468
+ private async isProcessAlive(pid: number, expectedStartTime?: number): Promise<boolean> {
469
+ try {
470
+ // Try to send signal 0 to check if process exists
471
+ process.kill(pid, 0);
472
+
473
+ // If expected start time is provided, verify it matches
474
+ if (expectedStartTime !== undefined) {
475
+ const currentStartTime = await this.getProcessStartTime(pid);
476
+ return currentStartTime === expectedStartTime;
477
+ }
478
+
479
+ return true;
480
+ } catch {
481
+ // Process doesn't exist or we can't check it
482
+ return false;
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Get process start time from /proc/{pid}/stat (Linux only)
488
+ * This is field 22 (starting after field 22, which starts after 21 spaces)
489
+ * @param pid Process ID
490
+ * @returns Process start time in jiffies (clock ticks since boot) or undefined
491
+ */
492
+ private async getProcessStartTime(pid: number): Promise<number | undefined> {
493
+ if (process.platform !== 'linux') {
494
+ return undefined;
495
+ }
496
+
497
+ try {
498
+ const statContent = await fs.readFile(`/proc/${pid}/stat`, 'utf-8');
499
+
500
+ // The stat file format is: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime priority nice num_threads itrealvalue starttime vsize rss rsslim startcode endcode startstack kstkesp kstkeip signal blocked sigignore sigcatch wchan nswap cnswap exit_signal processor rt_priority policy delayacct_blkio_ticks guest_time cguest_time start_data end_data start_brk arg_start arg_end env_start env_end exit_code
501
+ // Field 22 is starttime (process start time in jiffies)
502
+
503
+ // Split by space, but need to handle the comm field which can contain spaces
504
+ const parts = statContent.split(' ');
505
+
506
+ // Find the closing parenthesis of the comm field
507
+ let commEndIndex = 0;
508
+ for (let i = 1; i < parts.length; i++) {
509
+ const part = parts[i];
510
+ if (part?.includes(')')) {
511
+ commEndIndex = i;
512
+ break;
513
+ }
514
+ }
515
+
516
+ // Field 22 is at index (2 + 20) = 22 (0-indexed after comm)
517
+ // After comm (field 1), we skip to field 22
518
+ const startIndex = commEndIndex + 20;
519
+
520
+ if (startIndex < parts.length) {
521
+ const part = parts[startIndex];
522
+ if (part !== undefined) {
523
+ const starttimeStr = part.replace(')', '');
524
+ const starttime = parseInt(starttimeStr, 10);
525
+ return isNaN(starttime) ? undefined : starttime;
526
+ }
527
+ }
528
+
529
+ return undefined;
530
+ } catch {
531
+ // Can't read process stat file
532
+ return undefined;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Create a backup of the current state file
538
+ * @throws Error if unable to create backup
539
+ */
540
+ private async createBackup(): Promise<void> {
541
+ try {
542
+ // Check if state file exists
543
+ try {
544
+ await fs.access(this.stateFilePath);
545
+ } catch {
546
+ // No state file to backup
547
+ return;
548
+ }
549
+
550
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
551
+ const backupFileName = `searxng-singleton-${timestamp}.json`;
552
+ const backupFilePath = path.join(this.backupDirPath, backupFileName);
553
+
554
+ await fs.copyFile(this.stateFilePath, backupFilePath);
555
+ } catch (error: unknown) {
556
+ throw new Error(`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Clean up old backups, keeping only the most recent maxBackups
562
+ */
563
+ private async cleanupOldBackups(): Promise<void> {
564
+ try {
565
+ const entries = await fs.readdir(this.backupDirPath);
566
+
567
+ if (entries.length <= this.maxBackups) {
568
+ return;
569
+ }
570
+
571
+ // Get file stats and sort by modification time (newest first)
572
+ const backupFiles: Array<{ name: string; mtime: Date }> = [];
573
+
574
+ for (const entry of entries) {
575
+ const filePath = path.join(this.backupDirPath, entry);
576
+ const stats = await fs.stat(filePath);
577
+
578
+ if (stats.isFile() && entry.startsWith('searxng-singleton-') && entry.endsWith('.json')) {
579
+ backupFiles.push({ name: entry, mtime: stats.mtime });
580
+ }
581
+ }
582
+
583
+ // Sort by mtime, newest first
584
+ backupFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
585
+
586
+ // Remove backups beyond the max count
587
+ const backupsToRemove = backupFiles.slice(this.maxBackups);
588
+ for (const backupFile of backupsToRemove) {
589
+ const filePath = path.join(this.backupDirPath, backupFile.name);
590
+ await fs.unlink(filePath);
591
+ }
592
+ } catch (error: unknown) {
593
+ logger.error(`Failed to cleanup old backups: ${error instanceof Error ? error.message : String(error)}`);
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Recover from corruption by restoring a backup or writing a default state.
599
+ * Does NOT call withLock — safe to call when the caller already holds the lock.
600
+ */
601
+ private async recoverFromCorruptionDirect(): Promise<void> {
602
+ try {
603
+ // Find the newest valid backup
604
+ let newestBackup: { name: string; mtime: Date } | null = null;
605
+ try {
606
+ const entries = await fs.readdir(this.backupDirPath);
607
+ for (const entry of entries) {
608
+ const filePath = path.join(this.backupDirPath, entry);
609
+ const stats = await fs.stat(filePath);
610
+ if (stats.isFile() && entry.startsWith('searxng-singleton-') && entry.endsWith('.json')) {
611
+ if (newestBackup === null || stats.mtime > newestBackup.mtime) {
612
+ newestBackup = { name: entry, mtime: stats.mtime };
613
+ }
614
+ }
615
+ }
616
+ } catch {
617
+ // Backup directory unreadable — fall through to default state
618
+ }
619
+
620
+ if (newestBackup !== null) {
621
+ const backupPath = path.join(this.backupDirPath, newestBackup.name);
622
+ await fs.copyFile(backupPath, this.stateFilePath);
623
+ logger.log(`[StateManager] Recovered state from backup: ${newestBackup.name}`);
624
+ } else {
625
+ // No backups — write default state directly (atomic rename, no lock needed)
626
+ const defaultState = this.getDefaultState();
627
+ defaultState.lastUpdated = Date.now();
628
+ const tempFile = `searxng-singleton-${crypto.randomBytes(16).toString('hex')}.tmp`;
629
+ const tempPath = path.join(path.dirname(this.stateFilePath), tempFile);
630
+ await fs.writeFile(tempPath, JSON.stringify(defaultState, null, 2), 'utf-8');
631
+ await fs.rename(tempPath, this.stateFilePath);
632
+ logger.log('[StateManager] Recovered with default state (no backups available)');
633
+ }
634
+ } catch (error: unknown) {
635
+ const message = error instanceof Error ? error.message : String(error);
636
+ logger.error(`[StateManager] Failed to recover from corruption: ${message}`);
637
+ throw error;
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Acquire a filesystem lock for exclusive access to state
643
+ * @throws Error if unable to acquire lock within timeout
644
+ */
645
+ private async acquireLock(): Promise<void> {
646
+ await this.ensureDirectories();
647
+
648
+ const startTime = Date.now();
649
+
650
+ for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
651
+ try {
652
+ this.lockHandle = await fs.open(this.lockFilePath, 'wx');
653
+ return;
654
+ } catch (error: unknown) {
655
+ if (error instanceof Error && 'code' in error) {
656
+ const errnoError = error as NodeJS.ErrnoException;
657
+ if (errnoError.code === 'EEXIST') {
658
+ // Check if lock is stale
659
+ try {
660
+ const stats = await fs.stat(this.lockFilePath);
661
+ const lockAge = Date.now() - stats.mtimeMs;
662
+
663
+ if (lockAge > this.lockStaleThreshold) {
664
+ // Stale lock, remove it
665
+ await fs.unlink(this.lockFilePath);
666
+ continue;
667
+ }
668
+ } catch {
669
+ // Can't stat lock file, continue waiting
670
+ }
671
+
672
+ // Wait before retrying
673
+ if (Date.now() - startTime < this.lockTimeout) {
674
+ await this.sleep(this.lockRetryDelay);
675
+ continue;
676
+ }
677
+
678
+ }
679
+ }
680
+ throw error;
681
+ }
682
+ }
683
+
684
+ throw new Error(`Failed to acquire lock after ${this.lockRetries} retries`);
685
+ }
686
+
687
+ /**
688
+ * Release the filesystem lock
689
+ * @throws Error if unable to release lock
690
+ */
691
+ private async releaseLock(): Promise<void> {
692
+ if (this.lockHandle !== null) {
693
+ try {
694
+ await this.lockHandle.close();
695
+ this.lockHandle = null;
696
+ } catch (error: unknown) {
697
+ throw new Error(`Failed to close lock file handle: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
698
+ }
699
+ }
700
+
701
+ try {
702
+ await fs.unlink(this.lockFilePath);
703
+ } catch (error: unknown) {
704
+ if (error instanceof Error && 'code' in error) {
705
+ const errnoError = error as NodeJS.ErrnoException;
706
+ if (errnoError.code !== 'ENOENT') {
707
+ throw new Error(`Failed to remove lock file: ${errnoError.message}`, { cause: error });
708
+ }
709
+ }
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Execute a callback function while holding the lock with timeout.
715
+ * @param callback Async function to execute while holding the lock
716
+ * @param timeout Maximum time to hold the lock (default 30 seconds)
717
+ * @returns The return value of the callback
718
+ * @throws Error if unable to acquire lock, timeout, or callback throws
719
+ */
720
+ private async withLock<T>(
721
+ callback: () => Promise<T> | T,
722
+ timeout: number = 30000,
723
+ ): Promise<T> {
724
+ let lockAcquired = false;
725
+ let lockAcquisitionTimeoutHandle: NodeJS.Timeout | null = null;
726
+ let callbackTimeoutHandle: NodeJS.Timeout | null = null;
727
+
728
+ try {
729
+ // Acquire lock with timeout
730
+ await Promise.race([
731
+ this.acquireLock(),
732
+ new Promise<never>((_resolve, reject) => {
733
+ lockAcquisitionTimeoutHandle = setTimeout(() => {
734
+ reject(new Error(`Lock acquisition timed out after ${timeout}ms`));
735
+ }, timeout);
736
+ }),
737
+ ]);
738
+ lockAcquired = true;
739
+
740
+ // Clear lock acquisition timeout since we acquired the lock
741
+ if (lockAcquisitionTimeoutHandle) {
742
+ clearTimeout(lockAcquisitionTimeoutHandle);
743
+ lockAcquisitionTimeoutHandle = null;
744
+ }
745
+
746
+ // Execute callback with timeout
747
+ return await Promise.race([
748
+ callback(),
749
+ new Promise<never>((_resolve, reject) => {
750
+ callbackTimeoutHandle = setTimeout(() => {
751
+ reject(new Error(`Lock operation timed out after ${timeout}ms`));
752
+ }, timeout);
753
+ }),
754
+ ]);
755
+ } finally {
756
+ // Clear any remaining timeout handles
757
+ if (lockAcquisitionTimeoutHandle) {
758
+ clearTimeout(lockAcquisitionTimeoutHandle);
759
+ }
760
+ if (callbackTimeoutHandle) {
761
+ clearTimeout(callbackTimeoutHandle);
762
+ }
763
+
764
+ if (lockAcquired) {
765
+ try {
766
+ await this.releaseLock();
767
+ } catch (error: unknown) {
768
+ logger.error('[StateManager] Failed to release lock:', error);
769
+ }
770
+ }
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Validate the structure and version of a state object
776
+ * @param state The state object to validate
777
+ * @throws Error if state structure or version is invalid
778
+ */
779
+ private validateState(state: unknown): void {
780
+ if (!state || typeof state !== 'object') {
781
+ throw new Error('Invalid state: not an object');
782
+ }
783
+
784
+ if (!isSingletonState(state)) {
785
+ throw new Error('Invalid state: structure or version mismatch');
786
+ }
787
+
788
+ // Type guard already validated the structure, now validate values
789
+ if (state.port < 0 || state.port > 65535) {
790
+ throw new Error(`Invalid state: port must be a number between 0 and 65535, got ${state.port}`);
791
+ }
792
+
793
+ if (state.lastUpdated < 0) {
794
+ throw new Error(`Invalid state: lastUpdated must be a non-negative number, got ${state.lastUpdated}`);
795
+ }
796
+
797
+ for (const [sessionId, sessionData] of Object.entries(state.sessions)) {
798
+ if (typeof sessionId !== 'string') {
799
+ throw new Error('Invalid state: session IDs must be strings');
800
+ }
801
+
802
+ if (!isSessionInfo(sessionData)) {
803
+ throw new Error(`Invalid state: session data for ${sessionId} has invalid structure`);
804
+ }
805
+
806
+ if (sessionData.pid < 0) {
807
+ throw new Error(`Invalid state: pid for ${sessionId} must be a non-negative number, got ${sessionData.pid}`);
808
+ }
809
+
810
+ if (sessionData.processStartTime !== undefined && sessionData.processStartTime < 0) {
811
+ throw new Error(`Invalid state: processStartTime for ${sessionId} must be a non-negative number or undefined, got ${sessionData.processStartTime}`);
812
+ }
813
+
814
+ if (sessionData.lastSeen < 0) {
815
+ throw new Error(`Invalid state: lastSeen for ${sessionId} must be a non-negative number, got ${sessionData.lastSeen}`);
816
+ }
817
+
818
+ if (sessionData.connectedAt < 0) {
819
+ throw new Error(`Invalid state: connectedAt for ${sessionId} must be a non-negative number, got ${sessionData.connectedAt}`);
820
+ }
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Sleep for a specified number of milliseconds
826
+ * @param ms The number of milliseconds to sleep
827
+ */
828
+ private sleep(ms: number): Promise<void> {
829
+ return new Promise<void>((resolve) => setTimeout(resolve, ms));
830
+ }
831
+
832
+
833
+ // ==================== Backward compatibility methods ====================
834
+
835
+ /**
836
+ * Get a session by ID (backward compatible)
837
+ * @param sessionId The session ID to retrieve
838
+ * @returns The session data or null if not found
839
+ */
840
+ public async getSession(sessionId: string): Promise<LegacySessionInfo | null> {
841
+ const state = await this.readState();
842
+ const session = state.sessions[sessionId];
843
+
844
+ if (session === undefined) {
845
+ return null;
846
+ }
847
+
848
+ // Return only lastSeen for backward compatibility
849
+ return {
850
+ lastSeen: session.lastSeen,
851
+ };
852
+ }
853
+
854
+ /**
855
+ * Update the activity timestamp for a session (backward compatible)
856
+ * @param sessionId The session ID to update
857
+ */
858
+ public async updateActivity(sessionId: string): Promise<void> {
859
+ await this.updateHeartbeat(sessionId);
860
+ }
861
+
862
+ /**
863
+ * Get all sessions (backward compatible)
864
+ * @returns A copy of all sessions with legacy structure
865
+ */
866
+ public async getAllSessions(): Promise<{ [sessionId: string]: LegacySessionInfo }> {
867
+ const state = await this.readState();
868
+ const legacySessions: { [sessionId: string]: LegacySessionInfo } = {};
869
+
870
+ for (const [sessionId, sessionInfo] of Object.entries(state.sessions)) {
871
+ legacySessions[sessionId] = {
872
+ lastSeen: sessionInfo.lastSeen,
873
+ };
874
+ }
875
+
876
+ return legacySessions;
877
+ }
878
+
879
+ /**
880
+ * Clean up resources (release lock if held)
881
+ * Should be called when shutting down
882
+ */
883
+ public async cleanup(): Promise<void> {
884
+ if (this.lockHandle !== null) {
885
+ try {
886
+ await this.releaseLock();
887
+ } catch (error: unknown) {
888
+ logger.error('Failed to release lock during cleanup:', error);
889
+ }
890
+ }
891
+ }
892
+
893
+ // ==================== Public getter methods ====================
894
+
895
+ /**
896
+ * Get the state file path
897
+ * @returns The path to the state file
898
+ */
899
+ public getStateFilePath(): string {
900
+ return this.stateFilePath;
901
+ }
902
+
903
+ /**
904
+ * Get the lock file path
905
+ * @returns The path to the lock file
906
+ */
907
+ public getLockFilePath(): string {
908
+ return this.lockFilePath;
909
+ }
910
+
911
+ /**
912
+ * Get the backup directory path
913
+ * @returns The path to the backup directory
914
+ */
915
+ public getBackupDirPath(): string {
916
+ return this.backupDirPath;
917
+ }
918
+ }