@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
|
@@ -1,918 +0,0 @@
|
|
|
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
|
-
}
|