@gotcos/glasses-server 6.12.7 → 6.14.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.
@@ -43,6 +43,11 @@ import {
43
43
  type IndexRange,
44
44
  } from '../lib/local-first-meetings-contract.js'
45
45
  import { getServerInstanceId } from '../lib/server-instance-id.js'
46
+ import {
47
+ acquireMaintenanceWork,
48
+ maintenanceAdmissionsOpen,
49
+ type MaintenanceWorkLease,
50
+ } from '../lib/maintenance-lifecycle.js'
46
51
 
47
52
  function ensurePrivateDirectory(path: string): void {
48
53
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -264,6 +269,12 @@ interface StreamChunkCompletionResponse {
264
269
 
265
270
  const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
266
271
 
272
+ /** Conservative lifecycle count used by the local service manager. A restart
273
+ * is unsafe while any live transcription session owns audio state. */
274
+ export function getActiveTranscriptionSessionCount(): number {
275
+ return sessions.size
276
+ }
277
+
267
278
  // Incremental chunk persistence — survive server restarts
268
279
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
269
280
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)
@@ -600,12 +611,17 @@ export function isSessionDeleted(sessionId: string): boolean {
600
611
  return deletedSessions.has(sessionId)
601
612
  }
602
613
 
603
- // Recover any sessions from prior server instance.
604
- recoverClosedSessions()
605
- recoverSessions()
614
+ // A committed cross-boot maintenance operation owns durable state until the
615
+ // trusted controller adopts the candidate. Boot recovery must not mutate or
616
+ // promote sessions while that gate is closed.
617
+ if (maintenanceAdmissionsOpen()) {
618
+ recoverClosedSessions()
619
+ recoverSessions()
620
+ }
606
621
 
607
622
  // Auto-cleanup sessions idle for the advertised retention horizon.
608
623
  setInterval(() => {
624
+ if (!maintenanceAdmissionsOpen()) return
609
625
  const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
610
626
  for (const [id, session] of sessions) {
611
627
  if (session.lastActivityAt < cutoff) {
@@ -1550,10 +1566,14 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
1550
1566
  }
1551
1567
 
1552
1568
  transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1569
+ let maintenanceLease: MaintenanceWorkLease | undefined
1553
1570
  try {
1554
1571
  // Reject a wrong Mac before consuming or persisting any upload bytes.
1555
1572
  assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
1556
1573
  const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
1574
+ maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1575
+ allowDuringDrain: sessions.has(sessionId),
1576
+ })
1557
1577
  const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
1558
1578
  const clientSpeaker = (req.query.speaker as string) || 'Unknown'
1559
1579
  const clientElapsedRaw = Number(req.query.elapsed)
@@ -1575,15 +1595,21 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1575
1595
  }))
1576
1596
  } catch (err: unknown) {
1577
1597
  sendStreamError(res, err)
1598
+ } finally {
1599
+ maintenanceLease?.release()
1578
1600
  }
1579
1601
  })
1580
1602
 
1581
1603
  transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (req, res) => {
1604
+ let maintenanceLease: MaintenanceWorkLease | undefined
1582
1605
  try {
1583
1606
  if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1584
1607
  const body = req.body ?? {}
1585
1608
  const sessionId = String(body.sessionId ?? '')
1586
1609
  validateSessionId(sessionId)
1610
+ maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1611
+ allowDuringDrain: sessions.has(sessionId),
1612
+ })
1587
1613
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
1588
1614
  const session = getSession(sessionId)
1589
1615
  const startTime = typeof body.startTime === 'number' && Number.isFinite(body.startTime)
@@ -1596,14 +1622,20 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
1596
1622
  res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
1597
1623
  } catch (err: unknown) {
1598
1624
  sendStreamError(res, err)
1625
+ } finally {
1626
+ maintenanceLease?.release()
1599
1627
  }
1600
1628
  })
1601
1629
 
1602
1630
  transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chunks', async (req, res) => {
1631
+ let maintenanceLease: MaintenanceWorkLease | undefined
1603
1632
  try {
1604
1633
  if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1605
1634
  const sessionId = String(req.params.sessionId ?? '')
1606
1635
  validateSessionId(sessionId)
1636
+ maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1637
+ allowDuringDrain: sessions.has(sessionId),
1638
+ })
1607
1639
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
1608
1640
  const body = req.body ?? {}
1609
1641
  const chunkIndex = Number(body.chunkIndex)
@@ -1636,14 +1668,20 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
1636
1668
  res.json({ ...result, offlineReplay: true })
1637
1669
  } catch (err: unknown) {
1638
1670
  sendStreamError(res, err)
1671
+ } finally {
1672
+ maintenanceLease?.release()
1639
1673
  }
1640
1674
  })
1641
1675
 
1642
1676
  transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/finalize', async (req, res) => {
1677
+ let maintenanceLease: MaintenanceWorkLease | undefined
1643
1678
  try {
1644
1679
  if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1645
1680
  const sessionId = String(req.params.sessionId ?? '')
1646
1681
  validateSessionId(sessionId)
1682
+ maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1683
+ allowDuringDrain: sessions.has(sessionId),
1684
+ })
1647
1685
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
1648
1686
  const chunks = getSessionChunks(sessionId)
1649
1687
  if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
@@ -1662,16 +1700,22 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
1662
1700
  })
1663
1701
  } catch (err: unknown) {
1664
1702
  sendStreamError(res, err)
1703
+ } finally {
1704
+ maintenanceLease?.release()
1665
1705
  }
1666
1706
  })
1667
1707
 
1668
1708
  transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) => {
1709
+ let maintenanceLease: MaintenanceWorkLease | undefined
1669
1710
  try {
1670
1711
  if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1671
1712
  const body = req.body ?? {}
1672
1713
  const sessionId = String(body.sessionId ?? '')
1673
1714
  const chunkIndex = Number(body.chunkIndex)
1674
1715
  validateSessionId(sessionId)
1716
+ maintenanceLease = acquireMaintenanceWork('recording_chunk', {
1717
+ allowDuringDrain: sessions.has(sessionId),
1718
+ })
1675
1719
  validateChunkIndex(chunkIndex)
1676
1720
  if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
1677
1721
  if (chunkIndex > 0 && !sessions.has(sessionId)) {
@@ -1702,6 +1746,8 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
1702
1746
  res.json(result)
1703
1747
  } catch (err: unknown) {
1704
1748
  sendStreamError(res, err)
1749
+ } finally {
1750
+ maintenanceLease?.release()
1705
1751
  }
1706
1752
  })
1707
1753
 
@@ -11,6 +11,12 @@ import {
11
11
  OpenAIWhisperBudgetExhaustedError,
12
12
  TranscriptionUnavailableError,
13
13
  } from '../lib/transcribe-audio.js'
14
+ import {
15
+ acquireMaintenanceWork,
16
+ MaintenanceLifecycleError,
17
+ maintenanceErrorPayload,
18
+ type MaintenanceWorkLease,
19
+ } from '../lib/maintenance-lifecycle.js'
14
20
 
15
21
  export const transcribeRouter = Router()
16
22
 
@@ -23,7 +29,9 @@ function resolveMode(req: { body?: { mode?: string }; query?: { mode?: string |
23
29
 
24
30
  // Accept raw binary body up to 25MB (Whisper limit).
25
31
  transcribeRouter.post('/transcribe', async (req, res) => {
32
+ let maintenanceLease: MaintenanceWorkLease | undefined
26
33
  try {
34
+ maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
27
35
  const chunks: Buffer[] = []
28
36
  for await (const chunk of req) {
29
37
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
@@ -38,6 +46,10 @@ transcribeRouter.post('/transcribe', async (req, res) => {
38
46
  console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
39
47
  res.json({ text: result.text, backend: result.backend, mode: result.mode })
40
48
  } catch (err: any) {
49
+ if (err instanceof MaintenanceLifecycleError) {
50
+ if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
51
+ return res.status(err.status).json(maintenanceErrorPayload(err))
52
+ }
41
53
  if (err instanceof NoSpeechDetectedError) {
42
54
  console.log(`[perf] /transcribe: DROPPED (hallucination or empty): ${err.rawText.length} chars`)
43
55
  return res.status(204).send()
@@ -60,5 +72,7 @@ transcribeRouter.post('/transcribe', async (req, res) => {
60
72
  })
61
73
  }
62
74
  res.status(500).json({ error: err.message })
75
+ } finally {
76
+ maintenanceLease?.release()
63
77
  }
64
78
  })