claw-dashboard 1.9.0 → 2.1.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 (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5285 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -6
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1934 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1056 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,685 @@
1
+ /**
2
+ * Gateway Manager Module
3
+ * Manages multiple OpenClaw gateway endpoints and aggregates data
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import https from 'https';
8
+ import http from 'http';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ import logger from './logger.js';
12
+ import config, { DEFAULT_GATEWAY_ENDPOINT, GATEWAY } from './config.js';
13
+ import { GatewayError, NetworkError, AuthError, TimeoutError, ChecksumError } from './errors.js';
14
+ import { verifyResponseChecksum, getChecksumMetadata } from './checksum.js';
15
+
16
+ /**
17
+ * @typedef {Object} GatewayEndpoint
18
+ * @property {string} name - Endpoint display name
19
+ * @property {string} host - Hostname or IP address
20
+ * @property {number} port - Port number
21
+ * @property {string|null} token - Authentication token
22
+ * @property {boolean} enabled - Whether endpoint is enabled
23
+ * @property {string} type - Endpoint type: 'local', 'remote', 'cloud'
24
+ * @property {boolean} [reachable] - Last known reachability status
25
+ * @property {number} [lastSeen] - Timestamp of last successful connection
26
+ * @property {string} [error] - Last error message
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} AggregatedSession
31
+ * @property {string} key - Session key
32
+ * @property {string} channel - Channel name
33
+ * @property {string} displayName - Display name
34
+ * @property {number} updatedAt - Last update timestamp
35
+ * @property {string} sessionId - Session ID
36
+ * @property {string} model - Model name
37
+ * @property {number} contextTokens - Context window size
38
+ * @property {number} totalTokens - Total tokens used
39
+ * @property {string} kind - Session kind
40
+ * @property {Object} deliveryContext - Delivery context
41
+ * @property {boolean} systemSent - System message sent
42
+ * @property {boolean} abortedLastRun - Was last run aborted
43
+ * @property {string} lastChannel - Last channel
44
+ * @property {string} lastTo - Last recipient
45
+ * @property {string} lastAccountId - Last account ID
46
+ * @property {string} transcriptPath - Path to transcript
47
+ * @property {string} gatewayEndpoint - Name of gateway endpoint this session belongs to
48
+ * @property {string} gatewayHost - Host of gateway endpoint
49
+ */
50
+
51
+ // Promisified exec for async command execution
52
+ const execAsync = promisify(exec);
53
+
54
+ /**
55
+ * Get log filter function based on filter level
56
+ * @param {string} filter - Filter level ('all', 'error', 'warn', 'info', 'debug')
57
+ * @returns {Function} Filter function that takes a log line and returns boolean
58
+ */
59
+ function getLogFilterFn(filter) {
60
+ switch (filter) {
61
+ case 'error':
62
+ return (line) => line.includes('ERROR') || line.includes('error') || line.includes('ERR');
63
+ case 'warn':
64
+ return (line) => line.includes('WARN') || line.includes('warning') || line.includes('WARNING');
65
+ case 'info':
66
+ return (line) => !line.includes('DEBUG') && !line.includes('debug');
67
+ case 'debug':
68
+ case 'all':
69
+ default:
70
+ return () => true;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch OpenClaw logs from the system
76
+ * @param {Object} options - Options for fetching logs
77
+ * @param {number} options.limit - Maximum number of log lines to fetch (default: 200)
78
+ * @param {string} options.filter - Log level filter ('all', 'error', 'warn', 'info', 'debug')
79
+ * @returns {Promise<Object>} Object with logs array
80
+ */
81
+ export async function getOpenClawLogs(options = {}) {
82
+ const { limit = 200, filter = 'all' } = options;
83
+
84
+ try {
85
+ const { stdout } = await execAsync(
86
+ `openclaw logs --limit ${limit} --plain 2>/dev/null`,
87
+ { timeout: config.COMMAND_TIMEOUTS.OPENCLAW_LOGS }
88
+ );
89
+
90
+ const filterFn = getLogFilterFn(filter);
91
+ const lines = stdout
92
+ .trim()
93
+ .split('\n')
94
+ .filter(line => !line.includes('plugin CLI register skipped'))
95
+ .filter(line => filterFn(line))
96
+ .filter(line => line.length > 0);
97
+
98
+ return {
99
+ logs: lines,
100
+ count: lines.length,
101
+ timestamp: Date.now()
102
+ };
103
+ } catch (err) {
104
+ logger.debug(`Failed to fetch OpenClaw logs: ${err.message}`);
105
+ return {
106
+ logs: [],
107
+ count: 0,
108
+ timestamp: Date.now(),
109
+ error: err.message
110
+ };
111
+ }
112
+ }
113
+
114
+ class GatewayManager {
115
+ constructor() {
116
+ /** @type {GatewayEndpoint[]} */
117
+ this.endpoints = [];
118
+ /** @type {Map<string, number>} */
119
+ this.endpointLatency = new Map();
120
+ /** @type {Map<string, number>} */
121
+ this.endpointFailCount = new Map();
122
+ /** @type {Map<string, number>} */
123
+ this.endpointChecksumFailCount = new Map();
124
+ /** @type {Map<string, boolean>} */
125
+ this.endpointChecksumVerified = new Map();
126
+ }
127
+
128
+ /**
129
+ * Initialize the gateway manager with settings
130
+ * @param {Object} settings - Dashboard settings
131
+ */
132
+ init(settings) {
133
+ if (settings.gatewayEndpoints && Array.isArray(settings.gatewayEndpoints)) {
134
+ this.endpoints = settings.gatewayEndpoints.map(ep => ({
135
+ ...DEFAULT_GATEWAY_ENDPOINT,
136
+ ...ep,
137
+ // Ensure required fields
138
+ name: ep.name || 'unnamed',
139
+ host: ep.host || 'localhost',
140
+ port: ep.port || GATEWAY.DEFAULT_PORT,
141
+ enabled: ep.enabled !== false, // Default to true
142
+ }));
143
+ } else {
144
+ // Fallback to default single endpoint
145
+ this.endpoints = [{ ...DEFAULT_GATEWAY_ENDPOINT }];
146
+ }
147
+
148
+ logger.info(`GatewayManager initialized with ${this.endpoints.length} endpoint(s)`);
149
+ }
150
+
151
+ /**
152
+ * Get all enabled endpoints
153
+ * @returns {GatewayEndpoint[]}
154
+ */
155
+ getEnabledEndpoints() {
156
+ return this.endpoints.filter(ep => ep.enabled);
157
+ }
158
+
159
+ /**
160
+ * Get all endpoints (including disabled)
161
+ * @returns {GatewayEndpoint[]}
162
+ */
163
+ getAllEndpoints() {
164
+ return [...this.endpoints];
165
+ }
166
+
167
+ /**
168
+ * Get a specific endpoint by name
169
+ * @param {string} name - Endpoint name
170
+ * @returns {GatewayEndpoint|undefined}
171
+ */
172
+ getEndpoint(name) {
173
+ return this.endpoints.find(ep => ep.name === name);
174
+ }
175
+
176
+ /**
177
+ * Add a new endpoint
178
+ * @param {Partial<GatewayEndpoint>} endpointConfig - Endpoint configuration
179
+ * @returns {GatewayEndpoint|null} - The added endpoint or null if failed
180
+ */
181
+ addEndpoint(endpointConfig) {
182
+ if (this.endpoints.length >= GATEWAY.MAX_ENDPOINTS) {
183
+ logger.warn(`Cannot add endpoint: maximum of ${GATEWAY.MAX_ENDPOINTS} endpoints reached`);
184
+ return null;
185
+ }
186
+
187
+ // Check for duplicate names
188
+ if (this.endpoints.some(ep => ep.name === endpointConfig.name)) {
189
+ logger.warn(`Cannot add endpoint: name '${endpointConfig.name}' already exists`);
190
+ return null;
191
+ }
192
+
193
+ const newEndpoint = {
194
+ ...DEFAULT_GATEWAY_ENDPOINT,
195
+ ...endpointConfig,
196
+ enabled: true,
197
+ };
198
+
199
+ this.endpoints.push(newEndpoint);
200
+ logger.info(`Added gateway endpoint: ${newEndpoint.name} (${newEndpoint.host}:${newEndpoint.port})`);
201
+ return newEndpoint;
202
+ }
203
+
204
+ /**
205
+ * Remove an endpoint by name
206
+ * @param {string} name - Endpoint name to remove
207
+ * @returns {boolean} - True if removed, false if not found
208
+ */
209
+ removeEndpoint(name) {
210
+ const idx = this.endpoints.findIndex(ep => ep.name === name);
211
+ if (idx === -1) {
212
+ return false;
213
+ }
214
+
215
+ // Don't allow removing the last endpoint
216
+ if (this.endpoints.length <= 1) {
217
+ logger.warn('Cannot remove the last gateway endpoint');
218
+ return false;
219
+ }
220
+
221
+ this.endpoints.splice(idx, 1);
222
+ logger.info(`Removed gateway endpoint: ${name}`);
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * Update an endpoint
228
+ * @param {string} name - Endpoint name to update
229
+ * @param {Partial<GatewayEndpoint>} updates - Fields to update
230
+ * @returns {GatewayEndpoint|null} - Updated endpoint or null if not found
231
+ */
232
+ updateEndpoint(name, updates) {
233
+ const idx = this.endpoints.findIndex(ep => ep.name === name);
234
+ if (idx === -1) {
235
+ return null;
236
+ }
237
+
238
+ // Don't allow renaming to a duplicate name
239
+ if (updates.name && updates.name !== name && this.endpoints.some(ep => ep.name === updates.name)) {
240
+ logger.warn(`Cannot rename endpoint: name '${updates.name}' already exists`);
241
+ return null;
242
+ }
243
+
244
+ this.endpoints[idx] = { ...this.endpoints[idx], ...updates };
245
+ logger.info(`Updated gateway endpoint: ${name}`);
246
+ return this.endpoints[idx];
247
+ }
248
+
249
+ /**
250
+ * Toggle endpoint enabled state
251
+ * @param {string} name - Endpoint name
252
+ * @param {boolean} enabled - New enabled state
253
+ * @returns {boolean} - True if toggled, false if not found
254
+ */
255
+ toggleEndpoint(name, enabled) {
256
+ const ep = this.getEndpoint(name);
257
+ if (!ep) {
258
+ return false;
259
+ }
260
+
261
+ // Don't allow disabling the last enabled endpoint
262
+ if (!enabled && this.getEnabledEndpoints().length <= 1) {
263
+ logger.warn('Cannot disable the last enabled gateway endpoint');
264
+ return false;
265
+ }
266
+
267
+ ep.enabled = enabled;
268
+ logger.info(`Gateway endpoint ${name} ${enabled ? 'enabled' : 'disabled'}`);
269
+ return true;
270
+ }
271
+
272
+ /**
273
+ * Build sessions URL for an endpoint
274
+ * @param {GatewayEndpoint} endpoint
275
+ * @returns {string}
276
+ */
277
+ buildSessionsUrl(endpoint) {
278
+ const protocol = endpoint.port === 443 ? 'https' : 'http';
279
+ return `${protocol}://${endpoint.host}:${endpoint.port}/sessions`;
280
+ }
281
+
282
+ /**
283
+ * Fetch sessions from a single endpoint
284
+ * @param {GatewayEndpoint} endpoint
285
+ * @returns {Promise<AggregatedSession[]>}
286
+ */
287
+ async fetchSessionsFromEndpoint(endpoint) {
288
+ const startTime = Date.now();
289
+
290
+ try {
291
+ // First try HTTP API endpoint
292
+ const sessions = await this.fetchFromHttpApi(endpoint);
293
+ if (sessions && sessions.length > 0) {
294
+ // HTTP API succeeded - mark as reachable
295
+ // Note: checksum verification happens inside fetchFromHttpApi, so we only get here if it passed
296
+ this.updateEndpointHealth(endpoint.name, true, Date.now() - startTime, null, true, false);
297
+ return sessions.map(s => this.enrichSession(s, endpoint));
298
+ }
299
+ } catch (err) {
300
+ logger.debug(`HTTP API fetch failed for ${endpoint.name}: ${err.message}`);
301
+
302
+ // Handle checksum verification failures specifically
303
+ if (err instanceof ChecksumError) {
304
+ this.updateEndpointHealth(endpoint.name, false, null, err.message, false, true);
305
+ logger.error(`Checksum verification failed for ${endpoint.name}: ${err.message}`);
306
+ return [];
307
+ }
308
+ }
309
+
310
+ // Fallback to local file system for local endpoints
311
+ if (endpoint.type === 'local' || endpoint.host === 'localhost' || endpoint.host === '127.0.0.1') {
312
+ try {
313
+ const sessions = await this.fetchFromLocalFile(endpoint);
314
+ if (sessions) {
315
+ this.updateEndpointHealth(endpoint.name, true, Date.now() - startTime);
316
+ return sessions.map(s => this.enrichSession(s, endpoint));
317
+ }
318
+ } catch (err) {
319
+ logger.debug(`Local file fetch failed for ${endpoint.name}: ${err.message}`);
320
+ }
321
+ }
322
+
323
+ this.updateEndpointHealth(endpoint.name, false, null, 'Failed to fetch sessions');
324
+ return [];
325
+ }
326
+
327
+ /**
328
+ * Fetch sessions from HTTP API
329
+ * @param {GatewayEndpoint} endpoint
330
+ * @returns {Promise<Object[]|null>}
331
+ */
332
+ fetchFromHttpApi(endpoint) {
333
+ return new Promise((resolve, reject) => {
334
+ const url = this.buildSessionsUrl(endpoint);
335
+ const client = url.startsWith('https:') ? https : http;
336
+
337
+ const options = {
338
+ timeout: GATEWAY.TIMEOUT_MS,
339
+ headers: {
340
+ 'Accept': 'application/json',
341
+ },
342
+ };
343
+
344
+ if (endpoint.token) {
345
+ options.headers['Authorization'] = `Bearer ${endpoint.token}`;
346
+ }
347
+
348
+ const req = client.get(url, options, (res) => {
349
+ let data = '';
350
+
351
+ res.on('data', chunk => data += chunk);
352
+ res.on('end', () => {
353
+ if (res.statusCode === 200) {
354
+ try {
355
+ // Verify checksum if enabled and header present
356
+ const checksumResult = verifyResponseChecksum(res, data);
357
+
358
+ if (!checksumResult.verified) {
359
+ // Log checksum failure with metadata
360
+ const metadata = getChecksumMetadata(res);
361
+ logger.warn(`Checksum verification failed for ${endpoint.name}: ${checksumResult.error}`, {
362
+ endpoint: endpoint.name,
363
+ headerPresent: metadata.headerPresent,
364
+ strictMode: metadata.strictMode
365
+ });
366
+
367
+ reject(new ChecksumError(
368
+ `Response integrity check failed: ${checksumResult.error}`,
369
+ {
370
+ endpoint: endpoint.name,
371
+ headerName: metadata.headerName,
372
+ headerPresent: metadata.headerPresent
373
+ }
374
+ ));
375
+ return;
376
+ }
377
+
378
+ // Log successful checksum verification if header was present
379
+ if (checksumResult.checksum) {
380
+ logger.debug(`Checksum verified for ${endpoint.name}: ${checksumResult.checksum.substring(0, 16)}...`);
381
+ }
382
+
383
+ const parsed = JSON.parse(data);
384
+ resolve(Array.isArray(parsed) ? parsed : Object.values(parsed));
385
+ } catch (err) {
386
+ if (err instanceof ChecksumError) {
387
+ reject(err);
388
+ } else {
389
+ reject(new GatewayError(`Invalid JSON response: ${err.message}`));
390
+ }
391
+ }
392
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
393
+ reject(new AuthError(`Authentication failed for ${endpoint.name}`));
394
+ } else {
395
+ reject(new GatewayError(`HTTP ${res.statusCode}`));
396
+ }
397
+ });
398
+ });
399
+
400
+ req.on('error', (err) => {
401
+ reject(new NetworkError(`Connection error: ${err.message}`));
402
+ });
403
+
404
+ req.on('timeout', () => {
405
+ req.destroy();
406
+ reject(new TimeoutError('Request timeout'));
407
+ });
408
+
409
+ req.setTimeout(GATEWAY.TIMEOUT_MS);
410
+ });
411
+ }
412
+
413
+ /**
414
+ * Fetch sessions from local file system
415
+ * @param {GatewayEndpoint} endpoint
416
+ * @returns {Promise<Object[]|null>}
417
+ */
418
+ async fetchFromLocalFile(endpoint) {
419
+ const sessionsPath = config.PATHS.AGENTS_DIR + '/main/sessions/sessions.json';
420
+
421
+ if (!fs.existsSync(sessionsPath)) {
422
+ return null;
423
+ }
424
+
425
+ const data = fs.readFileSync(sessionsPath, 'utf8');
426
+ const sessionsObj = JSON.parse(data);
427
+
428
+ if (!sessionsObj || typeof sessionsObj !== 'object') {
429
+ return null;
430
+ }
431
+
432
+ return Object.entries(sessionsObj).map(([key, session]) => ({
433
+ key,
434
+ channel: session.channel || 'unknown',
435
+ displayName: session.displayName || key,
436
+ updatedAt: session.updatedAt || session.lastMessageAt || 0,
437
+ sessionId: session.sessionId || key,
438
+ model: session.model || 'unknown',
439
+ contextTokens: session.contextWindow || session.contextTokens || 0,
440
+ totalTokens: session.totalTokens || 0,
441
+ kind: session.kind || 'other',
442
+ deliveryContext: session.deliveryContext || {},
443
+ systemSent: session.systemSent || false,
444
+ abortedLastRun: session.abortedLastRun || false,
445
+ lastChannel: session.lastChannel || session.channel || '',
446
+ lastTo: session.lastTo || '',
447
+ lastAccountId: session.lastAccountId || '',
448
+ transcriptPath: session.transcriptPath || '',
449
+ }));
450
+ }
451
+
452
+ /**
453
+ * Enrich session with gateway endpoint info
454
+ * @param {Object} session
455
+ * @param {GatewayEndpoint} endpoint
456
+ * @returns {AggregatedSession}
457
+ */
458
+ enrichSession(session, endpoint) {
459
+ return {
460
+ ...session,
461
+ gatewayEndpoint: endpoint.name,
462
+ gatewayHost: `${endpoint.host}:${endpoint.port}`,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Update endpoint health status
468
+ * @param {string} name - Endpoint name
469
+ * @param {boolean} reachable - Whether endpoint is reachable
470
+ * @param {number|null} latency - Response latency in ms
471
+ * @param {string} [error] - Error message if failed
472
+ * @param {boolean} [checksumVerified] - Whether checksum verification passed
473
+ * @param {boolean} [checksumFailed] - Whether checksum verification failed
474
+ */
475
+ updateEndpointHealth(name, reachable, latency, error = null, checksumVerified = false, checksumFailed = false) {
476
+ const ep = this.getEndpoint(name);
477
+ if (!ep) return;
478
+
479
+ ep.reachable = reachable;
480
+ ep.lastSeen = reachable ? Date.now() : ep.lastSeen;
481
+ ep.error = error;
482
+
483
+ if (latency !== null) {
484
+ this.endpointLatency.set(name, latency);
485
+ }
486
+
487
+ if (reachable) {
488
+ this.endpointFailCount.set(name, 0);
489
+ } else {
490
+ const currentFails = this.endpointFailCount.get(name) || 0;
491
+ this.endpointFailCount.set(name, currentFails + 1);
492
+ }
493
+
494
+ // Track checksum verification status
495
+ if (checksumVerified) {
496
+ this.endpointChecksumVerified.set(name, true);
497
+ }
498
+ if (checksumFailed) {
499
+ const currentChecksumFails = this.endpointChecksumFailCount.get(name) || 0;
500
+ this.endpointChecksumFailCount.set(name, currentChecksumFails + 1);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Fetch sessions from all enabled endpoints
506
+ * @returns {Promise<{sessions: AggregatedSession[], stats: Object}>}
507
+ */
508
+ async fetchAllSessions() {
509
+ const enabledEndpoints = this.getEnabledEndpoints();
510
+
511
+ if (enabledEndpoints.length === 0) {
512
+ logger.warn('No enabled gateway endpoints');
513
+ return { sessions: [], stats: { totalEndpoints: 0, reachableEndpoints: 0 } };
514
+ }
515
+
516
+ // Fetch from all endpoints in parallel
517
+ const fetchPromises = enabledEndpoints.map(async (ep) => {
518
+ try {
519
+ const sessions = await this.fetchSessionsFromEndpoint(ep);
520
+ return { endpoint: ep.name, sessions, error: null };
521
+ } catch (err) {
522
+ logger.warn(`Failed to fetch from ${ep.name}: ${err.message}`);
523
+ return { endpoint: ep.name, sessions: [], error: err.message };
524
+ }
525
+ });
526
+
527
+ const results = await Promise.all(fetchPromises);
528
+
529
+ // Aggregate sessions and stats
530
+ const allSessions = [];
531
+ let reachableCount = 0;
532
+
533
+ for (const result of results) {
534
+ if (!result.error) {
535
+ reachableCount++;
536
+ allSessions.push(...result.sessions);
537
+ }
538
+ }
539
+
540
+ const stats = {
541
+ totalEndpoints: enabledEndpoints.length,
542
+ reachableEndpoints: reachableCount,
543
+ unreachableEndpoints: enabledEndpoints.length - reachableCount,
544
+ };
545
+
546
+ logger.debug(`Fetched ${allSessions.length} sessions from ${reachableCount}/${enabledEndpoints.length} endpoints`);
547
+
548
+ return { sessions: allSessions, stats };
549
+ }
550
+
551
+ /**
552
+ * Get endpoint health summary
553
+ * @returns {Object[]}
554
+ */
555
+ getEndpointHealth() {
556
+ return this.endpoints.map(ep => ({
557
+ name: ep.name,
558
+ host: ep.host,
559
+ port: ep.port,
560
+ enabled: ep.enabled,
561
+ reachable: ep.reachable || false,
562
+ lastSeen: ep.lastSeen || null,
563
+ latency: this.endpointLatency.get(ep.name) || null,
564
+ failCount: this.endpointFailCount.get(ep.name) || 0,
565
+ error: ep.error || null,
566
+ checksum: {
567
+ verified: this.endpointChecksumVerified.get(ep.name) || false,
568
+ failCount: this.endpointChecksumFailCount.get(ep.name) || 0,
569
+ enabled: config.CHECKSUM.ENABLED,
570
+ },
571
+ }));
572
+ }
573
+
574
+ /**
575
+ * Force a retry for a specific endpoint or all unreachable endpoints
576
+ * @param {string|null} endpointName - Name of endpoint to retry, or null for all unreachable
577
+ * @returns {Promise<Object>} - Result of retry attempts
578
+ */
579
+ async forceRetry(endpointName = null) {
580
+ const results = [];
581
+ const targets = endpointName
582
+ ? this.endpoints.filter(ep => ep.name === endpointName)
583
+ : this.endpoints.filter(ep => ep.enabled && !ep.reachable);
584
+
585
+ if (targets.length === 0) {
586
+ return { attempted: 0, results: [] };
587
+ }
588
+
589
+ logger.info(`Force retrying ${targets.length} endpoint(s)`);
590
+
591
+ for (const ep of targets) {
592
+ // Reset fail count for fresh attempt
593
+ this.endpointFailCount.set(ep.name, 0);
594
+
595
+ try {
596
+ const sessions = await this.fetchSessionsFromEndpoint(ep);
597
+ results.push({
598
+ name: ep.name,
599
+ success: ep.reachable === true,
600
+ sessions: sessions.length,
601
+ latency: this.endpointLatency.get(ep.name) || null,
602
+ error: ep.error || null,
603
+ });
604
+ } catch (err) {
605
+ results.push({
606
+ name: ep.name,
607
+ success: false,
608
+ sessions: 0,
609
+ latency: null,
610
+ error: err.message,
611
+ });
612
+ }
613
+ }
614
+
615
+ const successCount = results.filter(r => r.success).length;
616
+ logger.info(`Force retry complete: ${successCount}/${results.length} endpoints reachable`);
617
+
618
+ return {
619
+ attempted: results.length,
620
+ successful: successCount,
621
+ results,
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Get the number of consecutive failures for an endpoint
627
+ * @param {string} name - Endpoint name
628
+ * @returns {number} - Number of consecutive failures
629
+ */
630
+ getEndpointFailCount(name) {
631
+ return this.endpointFailCount.get(name) || 0;
632
+ }
633
+
634
+ /**
635
+ * Clear the failure count for an endpoint
636
+ * @param {string} name - Endpoint name
637
+ */
638
+ clearEndpointFailCount(name) {
639
+ this.endpointFailCount.set(name, 0);
640
+ logger.debug(`Cleared fail count for endpoint: ${name}`);
641
+ }
642
+
643
+ /**
644
+ * Get the total failure count across all endpoints
645
+ * @returns {number} - Total number of consecutive failures
646
+ */
647
+ getTotalFailCount() {
648
+ let total = 0;
649
+ for (const count of this.endpointFailCount.values()) {
650
+ total += count;
651
+ }
652
+ return total;
653
+ }
654
+
655
+ /**
656
+ * Clear all failure counts for all endpoints
657
+ */
658
+ clearAllFailCounts() {
659
+ for (const name of this.endpointFailCount.keys()) {
660
+ this.endpointFailCount.set(name, 0);
661
+ }
662
+ logger.debug('Cleared all endpoint failure counts');
663
+ }
664
+
665
+ /**
666
+ * Get settings object for saving
667
+ * @returns {Object}
668
+ */
669
+ getSettingsForSave() {
670
+ return {
671
+ gatewayEndpoints: this.endpoints.map(ep => ({
672
+ name: ep.name,
673
+ host: ep.host,
674
+ port: ep.port,
675
+ token: ep.token,
676
+ enabled: ep.enabled,
677
+ type: ep.type,
678
+ })),
679
+ };
680
+ }
681
+ }
682
+
683
+ // Export singleton instance
684
+ export const gatewayManager = new GatewayManager();
685
+ export default gatewayManager;