neoagent 2.4.1-beta.13 → 2.4.1-beta.15

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.
@@ -4,7 +4,8 @@ const { requireAuth } = require('../middleware/auth');
4
4
  const { sanitizeError } = require('../utils/security');
5
5
  const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
6
6
  const { TurnCoordinator } = require('../services/voice/turnCoordinator');
7
- const { normalizeVoiceSynthesisOptions } = require('../services/voice/providers');
7
+ const { normalizeVoiceSynthesisOptions, transcribeVoiceInput } = require('../services/voice/providers');
8
+ const { writeTempAudioFile, removeTempFile } = require('../services/voice/liveAudio');
8
9
  const { runVoiceTranscriptTurn } = require('../services/voice/turnRunner');
9
10
 
10
11
  const router = express.Router();
@@ -135,4 +136,38 @@ router.post('/respond', async (req, res) => {
135
136
  }
136
137
  });
137
138
 
139
+ router.post('/transcribe', async (req, res) => {
140
+ try {
141
+ const audioBase64 = String(req.body?.audioBase64 || '').trim();
142
+ const mimeType = String(req.body?.mimeType || 'audio/pcm;rate=16000;channels=1').trim();
143
+
144
+ if (!audioBase64) {
145
+ return res.status(400).json({ error: 'audioBase64 is required.' });
146
+ }
147
+
148
+ const approxBytes = (audioBase64.length * 3) / 4;
149
+ if (approxBytes > 25 * 1024 * 1024) {
150
+ return res.status(400).json({ error: 'Audio exceeds maximum size of 25MB.' });
151
+ }
152
+
153
+ const audioBytes = Buffer.from(audioBase64, 'base64');
154
+ const { filePath, mimeType: fileMimeType } = await writeTempAudioFile(audioBytes, mimeType);
155
+ let transcript = '';
156
+ try {
157
+ transcript = await transcribeVoiceInput(filePath, {
158
+ mimeType: fileMimeType,
159
+ userId: req.session.userId,
160
+ timeoutMs: 30000,
161
+ });
162
+ } finally {
163
+ await removeTempFile(filePath);
164
+ }
165
+
166
+ return res.json({ transcript: String(transcript || '').trim() });
167
+ } catch (err) {
168
+ const message = sanitizeError(err);
169
+ return res.status(500).json({ error: message });
170
+ }
171
+ });
172
+
138
173
  module.exports = router;
@@ -10,6 +10,7 @@ const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
10
10
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
11
11
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
12
12
  const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
13
+ const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
13
14
 
14
15
  function sha256(value) {
15
16
  return crypto.createHash('sha256').update(String(value || '')).digest('hex');
@@ -46,6 +47,7 @@ class BrowserExtensionRegistry {
46
47
  this.pairingTtlMs = Number(options.pairingTtlMs || process.env.NEOAGENT_BROWSER_EXTENSION_PAIRING_TTL_MS || DEFAULT_PAIRING_TTL_MS);
47
48
  this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_INTERVAL_MS || DEFAULT_HEARTBEAT_INTERVAL_MS);
48
49
  this.heartbeatTimeoutMs = Number(options.heartbeatTimeoutMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_TIMEOUT_MS || DEFAULT_HEARTBEAT_TIMEOUT_MS);
50
+ this.presenceTouchIntervalMs = Number(options.presenceTouchIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_PRESENCE_TOUCH_INTERVAL_MS || DEFAULT_PRESENCE_TOUCH_INTERVAL_MS);
49
51
  this.connectionsByUser = new Map();
50
52
  }
51
53
 
@@ -218,6 +220,7 @@ class BrowserExtensionRegistry {
218
220
  timeoutMs: this.commandTimeoutMs,
219
221
  heartbeatIntervalMs: this.heartbeatIntervalMs,
220
222
  heartbeatTimeoutMs: this.heartbeatTimeoutMs,
223
+ presenceTouchIntervalMs: this.presenceTouchIntervalMs,
221
224
  });
222
225
  userMap.set(tokenRow.id, connection);
223
226
  this.db.prepare(
@@ -315,6 +318,15 @@ class BrowserExtensionRegistry {
315
318
  return Boolean(this.getConnection(userId, tokenId)?.isOpen());
316
319
  }
317
320
 
321
+ touchPresence(userId, tokenId) {
322
+ const userMap = this.#getUserConnections(userId);
323
+ const connection = userMap?.get(String(tokenId));
324
+ if (!connection?.isOpen()) return;
325
+ this.db.prepare(
326
+ `UPDATE browser_extension_tokens SET last_seen_at = datetime('now') WHERE id = ?`
327
+ ).run(tokenId);
328
+ }
329
+
318
330
  async dispatch(userId, command, payload = {}, options = {}) {
319
331
  const connection = this.getConnection(userId, options.tokenId || payload.tokenId || null);
320
332
  if (!connection || !connection.isOpen()) {
@@ -329,10 +341,16 @@ class BrowserExtensionRegistry {
329
341
 
330
342
  getStatus(userId) {
331
343
  const userMap = this.#getUserConnections(userId);
332
- const selectedTokenId = this.getSelectedTokenId(userId);
333
- const connected = selectedTokenId
344
+ let selectedTokenId = this.getSelectedTokenId(userId);
345
+ let connected = selectedTokenId
334
346
  ? userMap?.get(selectedTokenId)
335
- : this.getConnection(userId);
347
+ : null;
348
+ if (!connected?.isOpen()) {
349
+ connected = this.getConnection(userId);
350
+ if (connected?.isOpen()) {
351
+ selectedTokenId = connected.tokenId;
352
+ }
353
+ }
336
354
  const effectiveSelectedTokenId = selectedTokenId || connected?.tokenId || null;
337
355
  const tokens = this.db.prepare(
338
356
  `SELECT id, name, status, last_connected_at, last_seen_at, revoked_at, created_at, metadata_json
@@ -400,7 +418,7 @@ class BrowserExtensionRegistry {
400
418
  }
401
419
 
402
420
  class ExtensionBrowserConnection {
403
- constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs }) {
421
+ constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs, presenceTouchIntervalMs }) {
404
422
  this.registry = registry;
405
423
  this.ws = ws;
406
424
  this.userId = userId;
@@ -409,13 +427,18 @@ class ExtensionBrowserConnection {
409
427
  this.timeoutMs = timeoutMs;
410
428
  this.heartbeatIntervalMs = heartbeatIntervalMs;
411
429
  this.heartbeatTimeoutMs = heartbeatTimeoutMs;
430
+ this.presenceTouchIntervalMs = presenceTouchIntervalMs;
412
431
  this.pending = new Map();
413
432
  this.connectedAt = new Date().toISOString();
414
433
  this.lastPongAt = Date.now();
434
+ this.lastPresenceTouchAt = 0;
415
435
  this.heartbeatTimer = null;
416
436
 
417
437
  ws.on('message', (data) => this.#handleMessage(data));
418
- ws.on('pong', () => { this.lastPongAt = Date.now(); });
438
+ ws.on('pong', () => {
439
+ this.lastPongAt = Date.now();
440
+ this.touchPresence();
441
+ });
419
442
  ws.on('close', () => this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.')));
420
443
  ws.on('error', (error) => this.#closePending(error));
421
444
  this.#startHeartbeat();
@@ -435,6 +458,21 @@ class ExtensionBrowserConnection {
435
458
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
436
459
  }
437
460
 
461
+ touchPresence({ force = false } = {}) {
462
+ const now = Date.now();
463
+ const intervalMs = Number(this.presenceTouchIntervalMs);
464
+ if (
465
+ !force
466
+ && Number.isFinite(intervalMs)
467
+ && intervalMs > 0
468
+ && now - this.lastPresenceTouchAt < intervalMs
469
+ ) {
470
+ return;
471
+ }
472
+ this.lastPresenceTouchAt = now;
473
+ this.registry.touchPresence(this.userId, this.tokenId);
474
+ }
475
+
438
476
  sendCommand(command, payload = {}, options = {}) {
439
477
  if (!this.isOpen()) {
440
478
  return Promise.reject(new ExtensionBrowserUnavailableError());
@@ -460,6 +498,7 @@ class ExtensionBrowserConnection {
460
498
 
461
499
  #handleMessage(data) {
462
500
  this.lastPongAt = Date.now();
501
+ this.touchPresence();
463
502
  let message;
464
503
  try {
465
504
  message = parseExtensionMessage(data);
@@ -497,6 +536,7 @@ class ExtensionBrowserConnection {
497
536
  const intervalMs = Number(this.heartbeatIntervalMs);
498
537
  const timeoutMs = Number(this.heartbeatTimeoutMs);
499
538
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
539
+ this.touchPresence({ force: true });
500
540
  this.heartbeatTimer = setInterval(() => {
501
541
  if (!this.isOpen()) {
502
542
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
@@ -507,6 +547,7 @@ class ExtensionBrowserConnection {
507
547
  this.#closePending(new ExtensionBrowserUnavailableError('Extension browser heartbeat timed out.'));
508
548
  return;
509
549
  }
550
+ this.touchPresence();
510
551
  try {
511
552
  this.ws.ping();
512
553
  } catch (error) {
@@ -10,6 +10,9 @@ const {
10
10
  } = require('./protocol');
11
11
 
12
12
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
13
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
14
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
15
+ const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
13
16
 
14
17
  function safeJson(value) {
15
18
  try {
@@ -47,6 +50,21 @@ class DesktopCompanionRegistry {
47
50
  || process.env.NEOAGENT_DESKTOP_COMMAND_TIMEOUT_MS
48
51
  || DEFAULT_COMMAND_TIMEOUT_MS,
49
52
  );
53
+ this.heartbeatIntervalMs = Number(
54
+ options.heartbeatIntervalMs
55
+ || process.env.NEOAGENT_DESKTOP_HEARTBEAT_INTERVAL_MS
56
+ || DEFAULT_HEARTBEAT_INTERVAL_MS,
57
+ );
58
+ this.heartbeatTimeoutMs = Number(
59
+ options.heartbeatTimeoutMs
60
+ || process.env.NEOAGENT_DESKTOP_HEARTBEAT_TIMEOUT_MS
61
+ || DEFAULT_HEARTBEAT_TIMEOUT_MS,
62
+ );
63
+ this.presenceTouchIntervalMs = Number(
64
+ options.presenceTouchIntervalMs
65
+ || process.env.NEOAGENT_DESKTOP_PRESENCE_TOUCH_INTERVAL_MS
66
+ || DEFAULT_PRESENCE_TOUCH_INTERVAL_MS,
67
+ );
50
68
  this.connectionsByUser = new Map();
51
69
  }
52
70
 
@@ -200,6 +218,9 @@ class DesktopCompanionRegistry {
200
218
  platform: record.platform,
201
219
  },
202
220
  timeoutMs: this.commandTimeoutMs,
221
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
222
+ heartbeatTimeoutMs: this.heartbeatTimeoutMs,
223
+ presenceTouchIntervalMs: this.presenceTouchIntervalMs,
203
224
  });
204
225
  // Install the new connection in the map BEFORE closing the old one.
205
226
  // This ensures that when the old socket's async 'close' event fires and
@@ -275,6 +296,19 @@ class DesktopCompanionRegistry {
275
296
  return this.getDeviceRecordByDeviceId(userId, deviceId);
276
297
  }
277
298
 
299
+ touchPresence(userId, deviceId) {
300
+ const userMap = this._getUserMap(userId);
301
+ const connection = userMap?.get(String(deviceId));
302
+ if (!connection?.isOpen()) return;
303
+ this.db.prepare(
304
+ `UPDATE desktop_companion_devices
305
+ SET status = 'online',
306
+ last_seen_at = datetime('now'),
307
+ updated_at = datetime('now')
308
+ WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
309
+ ).run(userId, deviceId);
310
+ }
311
+
278
312
  isConnected(userId) {
279
313
  const userMap = this._getUserMap(userId);
280
314
  return userMap != null && userMap.size > 0;
@@ -493,7 +527,19 @@ class DesktopCompanionRegistry {
493
527
  }
494
528
 
495
529
  class DesktopCompanionConnection {
496
- constructor({ registry, ws, userId, sessionId, deviceId, recordId, meta, timeoutMs }) {
530
+ constructor({
531
+ registry,
532
+ ws,
533
+ userId,
534
+ sessionId,
535
+ deviceId,
536
+ recordId,
537
+ meta,
538
+ timeoutMs,
539
+ heartbeatIntervalMs,
540
+ heartbeatTimeoutMs,
541
+ presenceTouchIntervalMs,
542
+ }) {
497
543
  this.registry = registry;
498
544
  this.ws = ws;
499
545
  this.userId = userId;
@@ -502,11 +548,22 @@ class DesktopCompanionConnection {
502
548
  this.recordId = recordId;
503
549
  this.meta = meta || {};
504
550
  this.timeoutMs = timeoutMs;
551
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
552
+ this.heartbeatTimeoutMs = heartbeatTimeoutMs;
553
+ this.presenceTouchIntervalMs = presenceTouchIntervalMs;
505
554
  this.pending = new Map();
555
+ this.lastPongAt = Date.now();
556
+ this.lastPresenceTouchAt = 0;
557
+ this.heartbeatTimer = null;
506
558
 
507
559
  ws.on('message', (data) => this._handleMessage(data));
560
+ ws.on('pong', () => {
561
+ this.lastPongAt = Date.now();
562
+ this.touchPresence();
563
+ });
508
564
  ws.on('close', () => this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.')));
509
565
  ws.on('error', (error) => this._closePending(error));
566
+ this._startHeartbeat();
510
567
  }
511
568
 
512
569
  isOpen() {
@@ -525,6 +582,21 @@ class DesktopCompanionConnection {
525
582
  this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
526
583
  }
527
584
 
585
+ touchPresence({ force = false } = {}) {
586
+ const now = Date.now();
587
+ const intervalMs = Number(this.presenceTouchIntervalMs);
588
+ if (
589
+ !force
590
+ && Number.isFinite(intervalMs)
591
+ && intervalMs > 0
592
+ && now - this.lastPresenceTouchAt < intervalMs
593
+ ) {
594
+ return;
595
+ }
596
+ this.lastPresenceTouchAt = now;
597
+ this.registry.touchPresence(this.userId, this.deviceId);
598
+ }
599
+
528
600
  sendCommand(command, payload = {}, options = {}) {
529
601
  if (!this.isOpen()) {
530
602
  return Promise.reject(new DesktopCompanionUnavailableError());
@@ -549,6 +621,8 @@ class DesktopCompanionConnection {
549
621
  }
550
622
 
551
623
  _handleMessage(data) {
624
+ this.lastPongAt = Date.now();
625
+ this.touchPresence();
552
626
  if (Buffer.isBuffer(data) && data.length > 0 && data[0] === FRAME_TYPE_VIDEO) {
553
627
  return;
554
628
  }
@@ -585,6 +659,10 @@ class DesktopCompanionConnection {
585
659
  }
586
660
 
587
661
  _closePending(error) {
662
+ if (this.heartbeatTimer) {
663
+ clearInterval(this.heartbeatTimer);
664
+ this.heartbeatTimer = null;
665
+ }
588
666
  for (const pending of this.pending.values()) {
589
667
  clearTimeout(pending.timer);
590
668
  pending.reject(error);
@@ -596,6 +674,31 @@ class DesktopCompanionConnection {
596
674
  // mark the device offline.
597
675
  this.registry.unregisterConnection(this);
598
676
  }
677
+
678
+ _startHeartbeat() {
679
+ const intervalMs = Number(this.heartbeatIntervalMs);
680
+ const timeoutMs = Number(this.heartbeatTimeoutMs);
681
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
682
+ this.touchPresence({ force: true });
683
+ this.heartbeatTimer = setInterval(() => {
684
+ if (!this.isOpen()) {
685
+ this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
686
+ return;
687
+ }
688
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0 && Date.now() - this.lastPongAt > timeoutMs) {
689
+ try { this.ws.terminate(); } catch {}
690
+ this._closePending(new DesktopCompanionUnavailableError('Desktop companion heartbeat timed out.'));
691
+ return;
692
+ }
693
+ this.touchPresence();
694
+ try {
695
+ this.ws.ping();
696
+ } catch (error) {
697
+ this._closePending(error);
698
+ }
699
+ }, intervalMs);
700
+ this.heartbeatTimer.unref?.();
701
+ }
599
702
  }
600
703
 
601
704
  module.exports = {
@@ -5,6 +5,18 @@ const { spawn } = require('child_process');
5
5
  const { EventEmitter } = require('events');
6
6
  const sharp = require('sharp');
7
7
 
8
+ let H264_FIRST_FRAME_TIMEOUT_MS = 6_000;
9
+ const envTimeout = process.env.NEOAGENT_ANDROID_STREAM_FIRST_FRAME_TIMEOUT_MS;
10
+
11
+ if (envTimeout) {
12
+ const parsed = parseInt(envTimeout, 10);
13
+ if (!isNaN(parsed) && parsed > 0) {
14
+ H264_FIRST_FRAME_TIMEOUT_MS = parsed;
15
+ } else {
16
+ console.warn('[AndroidStream] Invalid NEOAGENT_ANDROID_STREAM_FIRST_FRAME_TIMEOUT_MS; using default 6000ms');
17
+ }
18
+ }
19
+
8
20
  // Derive the full path to the `adb` binary the same way the Android controller does.
9
21
  function resolveAdbBin(sdkDir) {
10
22
  if (sdkDir) {
@@ -104,6 +116,7 @@ class AndroidStream {
104
116
  this._adbProc = null;
105
117
  this._ffmpegProc = null;
106
118
  this._restartTimer = null;
119
+ this._h264StartupTimer = null;
107
120
  this._seq = 0;
108
121
  this._lastErrorLogAt = 0;
109
122
  this._usePollingFallback = false; // set true when H.264 fails to start
@@ -181,6 +194,7 @@ class AndroidStream {
181
194
  this._ffmpegProc.stdout.on('data', (chunk) => parser.push(chunk));
182
195
  parser.on('frame', (jpeg) => {
183
196
  if (this._stopped) return;
197
+ this._clearH264StartupTimer();
184
198
  this.streamHub.handleFrame(this.userId, this.deviceId, {
185
199
  jpeg,
186
200
  platform: 'android',
@@ -197,6 +211,15 @@ class AndroidStream {
197
211
  this._logError('ffmpeg process error', err);
198
212
  });
199
213
 
214
+ if (H264_FIRST_FRAME_TIMEOUT_MS > 0) {
215
+ this._h264StartupTimer = setTimeout(() => {
216
+ if (this._stopped || this._usePollingFallback || this._seq > 0) return;
217
+ this._logError('H.264 stream produced no frames before timeout — using screencap fallback');
218
+ this._fallback();
219
+ }, H264_FIRST_FRAME_TIMEOUT_MS);
220
+ this._h264StartupTimer.unref?.();
221
+ }
222
+
200
223
  // Android screenrecord's hard limit is 180 s — restart at 170 s so there
201
224
  // is no gap in the stream.
202
225
  this._restartTimer = setTimeout(() => {
@@ -209,7 +232,7 @@ class AndroidStream {
209
232
 
210
233
  // If adb exits early (e.g. emulator restart), attempt recovery.
211
234
  this._adbProc.on('close', (code) => {
212
- if (this._stopped) return;
235
+ if (this._stopped || this._usePollingFallback) return;
213
236
  // If it exited immediately with a bad code, the device likely does not
214
237
  // support stdout screenrecord — fall back to screencap polling.
215
238
  if (code !== 0 && this._seq === 0) {
@@ -222,9 +245,18 @@ class AndroidStream {
222
245
  this._killProcesses();
223
246
  this._scheduleRestart(2000, () => this._launchH264());
224
247
  });
248
+
249
+ this._ffmpegProc.on('close', (code) => {
250
+ if (this._stopped || this._usePollingFallback) return;
251
+ if (this._seq === 0) {
252
+ this._logError(`ffmpeg exited (code ${code}) before producing any frames — using screencap fallback`);
253
+ this._fallback();
254
+ }
255
+ });
225
256
  }
226
257
 
227
258
  _fallback() {
259
+ this._killProcesses();
228
260
  this._usePollingFallback = true;
229
261
  if (!this._stopped) this._startPollingFallback();
230
262
  }
@@ -275,6 +307,7 @@ class AndroidStream {
275
307
  _killProcesses() {
276
308
  clearTimeout(this._restartTimer);
277
309
  this._restartTimer = null;
310
+ this._clearH264StartupTimer();
278
311
  // Kill ffmpeg first so it stops reading; then kill adb.
279
312
  try { this._ffmpegProc?.kill('SIGTERM'); } catch {}
280
313
  try { this._adbProc?.kill('SIGTERM'); } catch {}
@@ -282,6 +315,11 @@ class AndroidStream {
282
315
  this._adbProc = null;
283
316
  }
284
317
 
318
+ _clearH264StartupTimer() {
319
+ clearTimeout(this._h264StartupTimer);
320
+ this._h264StartupTimer = null;
321
+ }
322
+
285
323
  _logError(msg, err) {
286
324
  const now = Date.now();
287
325
  if (now - this._lastErrorLogAt > 10_000) {
@@ -12,6 +12,11 @@ const {
12
12
  const { getDeploymentInfo } = require('./deployment');
13
13
 
14
14
  const PACKAGE_JSON_PATH = path.join(APP_DIR, 'package.json');
15
+ const GIT_COMMAND_TIMEOUT_MS = 750;
16
+ const VERSION_CACHE_TTL_MS = 30 * 1000;
17
+
18
+ let cachedVersionInfo = null;
19
+ let cachedVersionInfoAt = 0;
15
20
 
16
21
  function readPackageVersion() {
17
22
  try {
@@ -22,7 +27,16 @@ function readPackageVersion() {
22
27
  }
23
28
  }
24
29
 
25
- function getVersionInfo() {
30
+ function readGitValue(command) {
31
+ return execSync(command, {
32
+ cwd: APP_DIR,
33
+ encoding: 'utf8',
34
+ stdio: ['ignore', 'pipe', 'ignore'],
35
+ timeout: GIT_COMMAND_TIMEOUT_MS,
36
+ }).trim();
37
+ }
38
+
39
+ function buildVersionInfo() {
26
40
  const packageVersion = readPackageVersion() || '0.0.0';
27
41
  const releaseChannel = readConfiguredReleaseChannel();
28
42
  const deployment = getDeploymentInfo();
@@ -32,24 +46,9 @@ function getVersionInfo() {
32
46
  let gitBranch = null;
33
47
 
34
48
  try {
35
- gitVersion =
36
- execSync('git describe --tags --always --dirty', {
37
- cwd: APP_DIR,
38
- encoding: 'utf8',
39
- stdio: ['ignore', 'pipe', 'ignore']
40
- })
41
- .trim()
42
- .replace(/^v/, '') || null;
43
- gitSha = execSync('git rev-parse --short HEAD', {
44
- cwd: APP_DIR,
45
- encoding: 'utf8',
46
- stdio: ['ignore', 'pipe', 'ignore']
47
- }).trim();
48
- gitBranch = execSync('git rev-parse --abbrev-ref HEAD', {
49
- cwd: APP_DIR,
50
- encoding: 'utf8',
51
- stdio: ['ignore', 'pipe', 'ignore']
52
- }).trim();
49
+ gitVersion = readGitValue('git describe --tags --always --dirty').replace(/^v/, '') || null;
50
+ gitSha = readGitValue('git rev-parse --short HEAD') || null;
51
+ gitBranch = readGitValue('git rev-parse --abbrev-ref HEAD') || null;
53
52
  } catch {
54
53
  gitSha = process.env.GIT_SHA || null;
55
54
  }
@@ -80,4 +79,15 @@ function getVersionInfo() {
80
79
  };
81
80
  }
82
81
 
82
+ function getVersionInfo() {
83
+ const now = Date.now();
84
+ if (cachedVersionInfo && now - cachedVersionInfoAt < VERSION_CACHE_TTL_MS) {
85
+ return { ...cachedVersionInfo };
86
+ }
87
+
88
+ cachedVersionInfo = buildVersionInfo();
89
+ cachedVersionInfoAt = now;
90
+ return { ...cachedVersionInfo };
91
+ }
92
+
83
93
  module.exports = { getVersionInfo };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const { test } = require('node:test');
5
+ const childProcess = require('child_process');
6
+
7
+ const VERSION_MODULE_PATH = require.resolve('./version');
8
+
9
+ function loadVersionModuleWithExecSync(execSync) {
10
+ const originalExecSync = childProcess.execSync;
11
+ delete require.cache[VERSION_MODULE_PATH];
12
+ childProcess.execSync = execSync;
13
+ try {
14
+ return require('./version');
15
+ } finally {
16
+ childProcess.execSync = originalExecSync;
17
+ }
18
+ }
19
+
20
+ test('getVersionInfo caches git metadata reads', () => {
21
+ let callCount = 0;
22
+ const { getVersionInfo } = loadVersionModuleWithExecSync((command) => {
23
+ callCount += 1;
24
+ if (command.includes('describe')) return 'v1.2.3\n';
25
+ if (command.includes('rev-parse --short')) return 'abc123\n';
26
+ if (command.includes('abbrev-ref')) return 'main\n';
27
+ throw new Error(`unexpected command: ${command}`);
28
+ });
29
+
30
+ const first = getVersionInfo();
31
+ const second = getVersionInfo();
32
+
33
+ assert.equal(first.gitVersion, '1.2.3');
34
+ assert.equal(first.gitSha, 'abc123');
35
+ assert.equal(first.gitBranch, 'main');
36
+ assert.equal(second.gitVersion, '1.2.3');
37
+ assert.equal(callCount, 3);
38
+ });
39
+