@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
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Shared State Manager
3
+ *
4
+ * Core implementation for cross-process state management.
5
+ * Manages the state file, locking, backups, and provides APIs for
6
+ * session and browser management.
7
+ *
8
+ * This implementation is decomposed into several sub-managers:
9
+ * - FileLockService for file locking
10
+ * - GPUResourceService for GPU lock management
11
+ * - StateSessionManager for session operations
12
+ * - StateBrowserManager for browser operations
13
+ * - StateBackupManager for backup/recovery
14
+ * - StateMetricsCollector for metrics
15
+ * - StateValidator for validation
16
+ * - StateSessionApi for session API
17
+ * - StateBrowserApi for browser API
18
+ */
19
+
20
+ import * as fs from 'node:fs/promises';
21
+ import * as path from 'node:path';
22
+ import * as crypto from 'node:crypto';
23
+ import process from 'node:process';
24
+ import { getGlobalConfigDir } from '../../config.ts';
25
+ import { logger } from '../../logger.ts';
26
+ import { metrics } from '../../utils/metrics.ts';
27
+ import type { IProcessLifecycle } from '../../core/interfaces/process-interfaces.ts';
28
+ import type {
29
+ StateMetrics,
30
+ SingletonState,
31
+ } from '../types/state-types.ts';
32
+ import { CURRENT_STATE_VERSION } from '../types/state-types.ts';
33
+ import { FileLockService } from '../file-lock-service.ts';
34
+ import type { GPUResourceService } from '../gpu-resource-service.ts';
35
+ import { StateBackupManager } from './state-backup-manager.ts';
36
+ import { StateVersionTooNewError } from './state-migration.ts';
37
+ import type { StateBrowserManager } from './state-browser-manager.ts';
38
+ import type { StateSessionManager } from './state-session-manager.ts';
39
+ import type { StateMetricsCollector } from './state-metrics.ts';
40
+ import type { StateValidator } from './state-validator.ts';
41
+ import { StateSessionApi } from './state-session-api.ts';
42
+ import { StateBrowserApi } from './state-browser-api.ts';
43
+
44
+ // Re-export commonly used types
45
+ export type { StateMetrics, SingletonState } from '../types/state-types.ts';
46
+
47
+ export interface StateManagerOptions {
48
+ stateDir?: string;
49
+ processLifecycle: IProcessLifecycle;
50
+ gpuResourceService: GPUResourceService;
51
+ sessionManager: StateSessionManager;
52
+ browserManager: StateBrowserManager;
53
+ metricsCollector: StateMetricsCollector;
54
+ validator: StateValidator;
55
+ fileLockService: FileLockService;
56
+ backupManager: StateBackupManager;
57
+ }
58
+
59
+ /**
60
+ * Shared State Manager Implementation
61
+ */
62
+ export class StateManager {
63
+ private stateFilePath: string;
64
+ private lockDirPath: string;
65
+ private backupDirPath: string;
66
+ private lockFilePath: string;
67
+
68
+ // Sub-services (injected via constructor)
69
+ private readonly fileLockService: FileLockService;
70
+ private readonly processLifecycle: IProcessLifecycle;
71
+ private readonly gpuResourceService: GPUResourceService;
72
+ private readonly sessionApi: StateSessionApi;
73
+ private readonly browserApi: StateBrowserApi;
74
+ private readonly backupManager: StateBackupManager;
75
+ private readonly metricsCollector: StateMetricsCollector;
76
+ private readonly validator: StateValidator;
77
+
78
+ // Set when a read finds the state file was written by a newer build than this
79
+ // process supports. While set, writes are suppressed so we never overwrite the
80
+ // newer file (which holds that build's sessions, browser lease, authSecret).
81
+ private stateTooNew = false;
82
+
83
+ // One-time guard for the orphaned-temp sweep below.
84
+ private orphanTmpSwept = false;
85
+
86
+ constructor(options: StateManagerOptions) {
87
+ const {
88
+ stateDir: providedStateDir,
89
+ processLifecycle,
90
+ fileLockService,
91
+ gpuResourceService,
92
+ sessionManager,
93
+ browserManager,
94
+ backupManager,
95
+ metricsCollector,
96
+ validator,
97
+ } = options;
98
+
99
+ let stateDir = providedStateDir;
100
+
101
+ if (!stateDir) {
102
+ // pi-research's own namespace (~/.pi/research/state), not the host pi root.
103
+ stateDir = path.join(getGlobalConfigDir(), 'state');
104
+ }
105
+
106
+ this.stateFilePath = path.join(stateDir, 'research-state.json');
107
+ this.lockDirPath = path.join(stateDir, '.locks');
108
+ this.backupDirPath = path.join(stateDir, 'backups');
109
+ this.lockFilePath = path.join(this.lockDirPath, 'research-state.lock');
110
+
111
+ // Use injected services
112
+ this.fileLockService = fileLockService;
113
+ this.processLifecycle = processLifecycle;
114
+ this.gpuResourceService = gpuResourceService;
115
+ this.sessionApi = new StateSessionApi(sessionManager);
116
+ this.browserApi = new StateBrowserApi(browserManager);
117
+ this.backupManager = backupManager;
118
+ this.metricsCollector = metricsCollector;
119
+ this.validator = validator;
120
+ }
121
+
122
+ /**
123
+ * Initialize directories with proper permissions
124
+ */
125
+ private async ensureDirectories(): Promise<void> {
126
+ const dirs = [
127
+ path.dirname(this.stateFilePath),
128
+ this.lockDirPath,
129
+ this.backupDirPath,
130
+ ];
131
+
132
+ for (const dir of dirs) {
133
+ try {
134
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
135
+ } catch (err) {
136
+ logger.error(`[StateManager] Failed to create directory ${dir}:`, err);
137
+ throw err;
138
+ }
139
+ }
140
+
141
+ await this.sweepOrphanedTempFiles();
142
+ }
143
+
144
+ /**
145
+ * Reclaim orphaned `research-state-*.tmp` files left in the state dir.
146
+ *
147
+ * Both the primary writer (_writeState) and the recovery writer
148
+ * (StateBackupManager.atomicWriteStateFile) write via temp+fsync+rename. The
149
+ * in-process `finally` cleans the temp on a failed write, but a process killed
150
+ * mid-write (SIGKILL, crash, OOM) between open and rename leaks the temp, and
151
+ * nothing else ever removes it — only backups/ is pruned. Sweep them here, once,
152
+ * on the first directory ensure.
153
+ *
154
+ * Only temps older than a safety window are removed: a live write holds the
155
+ * state lock and a temp exists for milliseconds, so anything older than the
156
+ * window cannot belong to a concurrent in-flight write (in this or another
157
+ * process). This makes the sweep safe without taking the state lock.
158
+ */
159
+ private async sweepOrphanedTempFiles(): Promise<void> {
160
+ if (this.orphanTmpSwept) return;
161
+ this.orphanTmpSwept = true;
162
+
163
+ const STALE_TMP_MS = 5 * 60 * 1000; // 5 minutes — far longer than any real write
164
+ const stateDir = path.dirname(this.stateFilePath);
165
+ const cutoff = Date.now() - STALE_TMP_MS;
166
+ try {
167
+ const entries = await fs.readdir(stateDir);
168
+ for (const entry of entries) {
169
+ if (!entry.startsWith('research-state-') || !entry.endsWith('.tmp')) continue;
170
+ const filePath = path.join(stateDir, entry);
171
+ try {
172
+ const stats = await fs.stat(filePath);
173
+ if (!stats.isFile() || stats.mtimeMs >= cutoff) continue;
174
+ await fs.unlink(filePath);
175
+ logger.debug(`[StateManager] Removed orphaned state temp file: ${entry}`);
176
+ } catch {
177
+ // Raced with another sweeper or the file vanished — ignore.
178
+ }
179
+ }
180
+ } catch (err) {
181
+ // Never let cleanup hygiene fail directory setup.
182
+ logger.debug('[StateManager] Orphaned temp sweep failed (non-fatal):', err);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Get the default state
188
+ */
189
+ private getDefaultState(): SingletonState {
190
+ return {
191
+ version: CURRENT_STATE_VERSION,
192
+ containerId: crypto.randomBytes(16).toString('hex'),
193
+ containerName: 'pi-research-shared-state',
194
+ port: 0,
195
+ sessions: {},
196
+ lastUpdated: Date.now(),
197
+ };
198
+ }
199
+
200
+ // ==================== Core State Operations ====================
201
+
202
+ /**
203
+ * Internal read without lock acquisition (caller must hold lock)
204
+ */
205
+ private async _readState(): Promise<SingletonState> {
206
+ // Once this process has seen a newer-build state file, it runs read-only on an in-memory
207
+ // default for the rest of its life (writes are suppressed in _writeState). Short-circuit
208
+ // here so every subsequent read does NOT re-open the file, re-throw StateVersionTooNewError,
209
+ // and re-quarantine a fresh copy on each call — which, on a hot path (heartbeats, GPU lock,
210
+ // metrics), produced an unbounded storm of `*.quarantine` copies under the state lock.
211
+ if (this.stateTooNew) {
212
+ return this.getDefaultState();
213
+ }
214
+ try {
215
+ const content = await fs.readFile(this.stateFilePath, 'utf-8');
216
+ const state = JSON.parse(content) as unknown;
217
+ return this.validator.migrateAndValidate(state);
218
+ } catch (error: unknown) {
219
+ if (typeof error === 'object' && error !== null && 'code' in error) {
220
+ const errnoError = error as NodeJS.ErrnoException;
221
+ if (errnoError.code === 'ENOENT') {
222
+ return this.getDefaultState();
223
+ }
224
+ }
225
+
226
+ // A file written by a NEWER build is not corruption. Recovering/overwriting
227
+ // it would destroy that build's live sessions, browser lease, and
228
+ // authSecret. Instead: quarantine a copy for forensics, mark this process
229
+ // read-only so no write clobbers the newer file, and run on an in-memory
230
+ // default. Normal service resumes once this build is upgraded.
231
+ if (error instanceof StateVersionTooNewError) {
232
+ logger.error(
233
+ `[StateManager] ${error.message} Running in read-only mode on an in-memory default; writes are suppressed to protect the newer file.`,
234
+ );
235
+ this.stateTooNew = true;
236
+ await this.backupManager
237
+ .quarantineFutureState(error.fileVersion)
238
+ .catch((qErr: unknown) =>
239
+ logger.warn(
240
+ `[StateManager] Failed to quarantine newer state file: ${qErr instanceof Error ? qErr.message : String(qErr)}`,
241
+ ),
242
+ );
243
+ return this.getDefaultState();
244
+ }
245
+
246
+ // Corruption is not only truncated/garbage JSON — a well-formed file can
247
+ // still fail schema validation (missing field, out-of-range port). These
248
+ // throw from JSON.parse (SyntaxError) or validateState ("Invalid state:
249
+ // ..."). Treat every such case as recoverable so a single bad file can
250
+ // never brick every reader; genuine I/O errors (EACCES/EPERM/EISDIR) are
251
+ // NOT corruption and still surface via the throw below. A future `version`
252
+ // is handled separately above (quarantine, not overwrite).
253
+ // A missing migration step ("No migration registered from state version
254
+ // N…") means an old, un-upgradeable file — recover to a fresh default like
255
+ // any other corruption. A buggy migration ("Migration … produced version
256
+ // …") is a programming error and is intentionally left to surface.
257
+ const isCorruption =
258
+ error instanceof SyntaxError ||
259
+ (error instanceof Error &&
260
+ (error.message.includes('parse') ||
261
+ error.message.startsWith('Invalid state') ||
262
+ error.message.startsWith('No migration registered')));
263
+ if (isCorruption) {
264
+ logger.error(
265
+ `[StateManager] State file corrupt/invalid (${error instanceof Error ? error.message : String(error)}); attempting recovery...`,
266
+ );
267
+ try {
268
+ await this.recoverFromCorruptionDirect();
269
+ const recovered = await fs.readFile(this.stateFilePath, 'utf-8');
270
+ return this.validator.migrateAndValidate(JSON.parse(recovered) as unknown);
271
+ } catch (recoveryError) {
272
+ logger.error(
273
+ `[StateManager] Recovery failed (${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}); using in-memory default state.`,
274
+ );
275
+ return this.getDefaultState();
276
+ }
277
+ }
278
+
279
+ throw new Error(`Failed to read state: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Read the state from the file system
285
+ * @param skipLock If true, skip acquiring the file lock (caller must hold it)
286
+ */
287
+ public async readState(skipLock?: boolean): Promise<SingletonState> {
288
+ await this.ensureDirectories();
289
+ if (skipLock) {
290
+ return await this._readState();
291
+ }
292
+ return await this.fileLockService.withLock(async () => {
293
+ return await this._readState();
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Write the state to the file system atomically with backup creation
299
+ */
300
+ public async writeState(state: SingletonState): Promise<void> {
301
+ await this.ensureDirectories();
302
+ const startTime = Date.now();
303
+ try {
304
+ await this.fileLockService.withLock(() => this._writeState(state));
305
+ const duration = Date.now() - startTime;
306
+ metrics.observe('state_operation_duration_ms', duration, { operation: 'write', status: 'success' });
307
+ metrics.increment('state_operations_total', 1, { operation: 'write', status: 'success' });
308
+ } catch (error) {
309
+ const duration = Date.now() - startTime;
310
+ metrics.observe('state_operation_duration_ms', duration, { operation: 'write', status: 'error' });
311
+ metrics.increment('state_operations_total', 1, { operation: 'write', status: 'error' });
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Internal write without lock acquisition (caller must hold lock)
318
+ */
319
+ private async _writeState(state: SingletonState): Promise<void> {
320
+ // A prior read found a newer-build state file on disk. Suppress the write so
321
+ // we do not clobber that build's live state with our older-schema view. The
322
+ // in-memory updates are lost, which is the intended degradation: data loss
323
+ // of the newer file is the far worse outcome. (All mutations flow through
324
+ // updateState, which reads — and so sets this flag — before writing.)
325
+ if (this.stateTooNew) {
326
+ logger.warn('[StateManager] Skipping state write: on-disk state was written by a newer build (read-only mode).');
327
+ return;
328
+ }
329
+ this.validator.validateState(state);
330
+ state.lastUpdated = Date.now();
331
+
332
+ let tempFilePath: string | null = null;
333
+ try {
334
+ // Backups are a safety net, not a prerequisite for writing. The atomic
335
+ // temp+fsync+rename below already protects the primary file, so a failed
336
+ // backup (e.g. unwritable backups/ dir) must NOT fail-close every state
337
+ // write — log and continue. This also keeps backup I/O from extending the
338
+ // time the state lock is held on a degraded disk.
339
+ await this.backupManager.createBackup().catch((err: unknown) => {
340
+ logger.warn(`[StateManager] Backup before write failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
341
+ });
342
+ const tempFileName = `research-state-${crypto.randomBytes(16).toString('hex')}.tmp`;
343
+ tempFilePath = path.join(path.dirname(this.stateFilePath), tempFileName);
344
+ const content = JSON.stringify(state, null, 2);
345
+ // Open, write, fsync, close — ensures data is on disk before rename is visible.
346
+ const fh = await fs.open(tempFilePath, 'w', 0o600);
347
+ try {
348
+ await fh.writeFile(content, { encoding: 'utf-8' });
349
+ await fh.sync();
350
+ } finally {
351
+ await fh.close();
352
+ }
353
+ try {
354
+ await fs.rename(tempFilePath, this.stateFilePath);
355
+ } catch (renameErr) {
356
+ // fs.rename fails on Windows if target exists (NTFS). Fall back to copy+delete.
357
+ if (process.platform === 'win32') {
358
+ await fs.copyFile(tempFilePath, this.stateFilePath);
359
+ // copyFile succeeded → the state IS persisted. Make the temp-file removal
360
+ // best-effort: a transient AV lock on the temp would otherwise throw here and
361
+ // fall through to the catch below, reporting "Failed to write state" (status:
362
+ // error) for a write that actually succeeded — a caller that retries/aborts on
363
+ // the false failure would act wrongly.
364
+ const tmp = tempFilePath;
365
+ tempFilePath = null;
366
+ await fs.unlink(tmp).catch((err) => {
367
+ logger.warn('[StateManager] Failed to remove temp file after copy fallback (write succeeded):', err);
368
+ });
369
+ } else {
370
+ throw renameErr;
371
+ }
372
+ }
373
+ tempFilePath = null;
374
+ await this.backupManager.cleanupOldBackups();
375
+ } catch (error: unknown) {
376
+ throw new Error(`Failed to write state: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
377
+ } finally {
378
+ if (tempFilePath) {
379
+ await fs.unlink(tempFilePath).catch((err) => {
380
+ logger.warn('[StateManager] Failed to clean up temp file:', err);
381
+ });
382
+ }
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Update state atomically using an updater function
388
+ */
389
+ public async updateState(updater: (state: SingletonState) => SingletonState | Promise<SingletonState>): Promise<void> {
390
+ await this.ensureDirectories();
391
+ const startTime = Date.now();
392
+ try {
393
+ await this.fileLockService.withLock(async () => {
394
+ const currentState = await this._readState();
395
+ const newState = await updater(currentState);
396
+ await this._writeState(newState);
397
+ });
398
+ const duration = Date.now() - startTime;
399
+ metrics.observe('state_operation_duration_ms', duration, { operation: 'update', status: 'success' });
400
+ metrics.increment('state_operations_total', 1, { operation: 'update', status: 'success' });
401
+ } catch (error) {
402
+ const duration = Date.now() - startTime;
403
+ metrics.observe('state_operation_duration_ms', duration, { operation: 'update', status: 'error' });
404
+ metrics.increment('state_operations_total', 1, { operation: 'update', status: 'error' });
405
+ throw error;
406
+ }
407
+ }
408
+
409
+ // ==================== Session API ====================
410
+
411
+ public async addSession(sessionId: string, param: number | string): Promise<void> {
412
+ await this.sessionApi.addSession(
413
+ sessionId,
414
+ param,
415
+ this.updateState.bind(this),
416
+ process.pid,
417
+ this.processLifecycle.getProcessStartTime.bind(this.processLifecycle)
418
+ );
419
+ }
420
+
421
+ public async removeSession(sessionId: string): Promise<void> {
422
+ await this.sessionApi.removeSession(sessionId, this.updateState.bind(this));
423
+ }
424
+
425
+ public async updateHeartbeat(sessionId: string): Promise<void> {
426
+ await this.sessionApi.updateHeartbeat(sessionId, this.updateState.bind(this));
427
+ }
428
+
429
+ public async cleanupStaleSessions(timeoutMs: number): Promise<number> {
430
+ return this.sessionApi.cleanupStaleSessions(timeoutMs, this.readState.bind(this), this.updateState.bind(this));
431
+ }
432
+
433
+ // ==================== Browser API ====================
434
+
435
+ public async getBrowserServer(): Promise<{ port: number; pid: number; schedulerId?: string; startTime?: number | null; authSecret?: string } | null> {
436
+ return this.browserApi.getBrowserServer(this.readState.bind(this));
437
+ }
438
+
439
+ public async setBrowserServer(port: number, pid: number, schedulerId?: string, authSecret?: string): Promise<void> {
440
+ await this.browserApi.setBrowserServer(
441
+ port,
442
+ pid,
443
+ schedulerId,
444
+ this.updateState.bind(this),
445
+ this.processLifecycle.getProcessStartTime.bind(this.processLifecycle),
446
+ authSecret
447
+ );
448
+ }
449
+
450
+ public async clearBrowserServer(): Promise<void> {
451
+ await this.browserApi.clearBrowserServer(this.updateState.bind(this));
452
+ }
453
+
454
+ // ==================== Embedding Server API ====================
455
+
456
+ public async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string } | null> {
457
+ const state = await this.readState();
458
+ return state.embeddingServer ?? null;
459
+ }
460
+
461
+ public async clearEmbeddingServer(): Promise<void> {
462
+ await this.updateState((state) => {
463
+ delete state.embeddingServer;
464
+ return state;
465
+ });
466
+ }
467
+
468
+ // ==================== Process API ====================
469
+
470
+ public async isPidAlive(pid: number, expectedSchedulerId?: string, skipLock?: boolean): Promise<boolean> {
471
+ const state = await this.readState(skipLock);
472
+ const expectedStartTime = state.browserServer?.pid === pid ? state.browserServer.startTime : undefined;
473
+
474
+ return this.processLifecycle.isPidAlive(pid, expectedSchedulerId, {
475
+ getState: (s?: boolean) => this.readState(s ?? skipLock),
476
+ skipLock,
477
+ getSchedulerIdFromState: (state: SingletonState) => state.browserServer?.schedulerId,
478
+ expectedStartTime,
479
+ });
480
+ }
481
+
482
+ // ==================== GPU Lock API ====================
483
+
484
+ public async acquireGpuLock(sessionId?: string, timeoutMs?: number): Promise<boolean> {
485
+ return this.gpuResourceService.acquireGpuLock(this.updateState.bind(this) as any, sessionId, timeoutMs);
486
+ }
487
+
488
+ public async releaseGpuLock(pid?: number): Promise<void> {
489
+ await this.gpuResourceService.releaseGpuLock(this.updateState.bind(this) as any, pid);
490
+ }
491
+
492
+ public async getGpuOwner(): Promise<SingletonState['gpuOwner'] | null> {
493
+ const state = await this.readState();
494
+ return state.gpuOwner || null;
495
+ }
496
+
497
+ // ==================== Metrics ====================
498
+
499
+ public async getMetrics(): Promise<StateMetrics> {
500
+ const state = await this.readState();
501
+ return this.metricsCollector.getMetrics(state);
502
+ }
503
+
504
+ // ==================== Recovery ====================
505
+
506
+ private async recoverFromCorruptionDirect(): Promise<void> {
507
+ try {
508
+ await this.backupManager.recoverFromCorruption();
509
+ } catch (error: unknown) {
510
+ const message = error instanceof Error ? error.message : String(error);
511
+ logger.error(`[StateManager] Failed to recover from corruption: ${message}`);
512
+ throw error;
513
+ }
514
+ }
515
+
516
+ // ==================== Cleanup ====================
517
+
518
+ public async cleanup(): Promise<void> {
519
+ await this.fileLockService.cleanup();
520
+ }
521
+
522
+ // ==================== Public Getters ====================
523
+
524
+ public getStateFilePath(): string {
525
+ return this.stateFilePath;
526
+ }
527
+
528
+ public getLockFilePath(): string {
529
+ return this.lockFilePath;
530
+ }
531
+
532
+ public getBackupDirPath(): string {
533
+ return this.backupDirPath;
534
+ }
535
+
536
+ getFileLockService(): FileLockService {
537
+ return this.fileLockService;
538
+ }
539
+
540
+ getProcessLifecycleService(): IProcessLifecycle {
541
+ return this.processLifecycle;
542
+ }
543
+
544
+ getGpuResourceService(): GPUResourceService {
545
+ return this.gpuResourceService;
546
+ }
547
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * State Metrics
3
+ *
4
+ * Handles metrics collection for the StateManager.
5
+ */
6
+
7
+ import type { StateMetrics, SingletonState } from '../types/state-types.ts';
8
+ import { metrics } from '../../utils/metrics.ts';
9
+ import { ServiceLifecycle, type IService } from '../../core/service-registry.ts';
10
+ import { ServiceNames } from '../../core/interfaces/service-names.ts';
11
+
12
+ /**
13
+ * Collects metrics from state
14
+ */
15
+ export class StateMetricsCollector implements IService {
16
+ readonly name = ServiceNames.STATE_METRICS_COLLECTOR;
17
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
18
+ /**
19
+ * Get metrics about the current state
20
+ * @param state The current state
21
+ * @returns StateMetrics object with various statistics
22
+ */
23
+ getMetrics(state: SingletonState): StateMetrics {
24
+ const now = Date.now();
25
+
26
+ const sessionEntries = Object.entries(state.sessions);
27
+ const totalSessions = sessionEntries.length;
28
+
29
+ let activeSessions = 0;
30
+ let oldestSession: number | null = null;
31
+ let newestSession: number | null = null;
32
+ let lastHeartbeatAge: number | null = null;
33
+
34
+ for (const [, sessionInfo] of sessionEntries) {
35
+ // Check if session is active (lastSeen within last 5 minutes)
36
+ if (now - sessionInfo.lastSeen < 300000) {
37
+ activeSessions++;
38
+ }
39
+
40
+ // Track oldest and newest connection times
41
+ if (oldestSession === null || sessionInfo.connectedAt < oldestSession) {
42
+ oldestSession = sessionInfo.connectedAt;
43
+ }
44
+
45
+ if (newestSession === null || sessionInfo.connectedAt > newestSession) {
46
+ newestSession = sessionInfo.connectedAt;
47
+ }
48
+
49
+ // Track last heartbeat age
50
+ const heartbeatAge = now - sessionInfo.lastSeen;
51
+ if (lastHeartbeatAge === null || heartbeatAge > lastHeartbeatAge) {
52
+ lastHeartbeatAge = heartbeatAge;
53
+ }
54
+ }
55
+
56
+ // Calculate container uptime if container exists
57
+ let containerUptime: number | null = null;
58
+ if (state.containerId !== '') {
59
+ // Use lastUpdated as approximation for container uptime
60
+ containerUptime = now - state.lastUpdated;
61
+ }
62
+
63
+ // Export to metrics system
64
+ metrics.setGauge('state_sessions_total', totalSessions);
65
+ metrics.setGauge('state_sessions_active', activeSessions);
66
+ metrics.setGauge('state_browser_server_exists', state.browserServer ? 1 : 0);
67
+ metrics.setGauge('state_gpu_lock_owner_exists', state.gpuOwner ? 1 : 0);
68
+
69
+ return {
70
+ totalSessions,
71
+ activeSessions,
72
+ oldestSession,
73
+ newestSession,
74
+ containerUptime,
75
+ lastHeartbeatAge,
76
+ };
77
+ }
78
+
79
+ async initialize(): Promise<void> {
80
+ if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
81
+ return;
82
+ }
83
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
84
+ }
85
+
86
+ async dispose(): Promise<void> {
87
+ if (this.lifecycle === ServiceLifecycle.DISPOSED) {
88
+ return;
89
+ }
90
+ this.lifecycle = ServiceLifecycle.DISPOSED;
91
+ }
92
+ }