@gotcos/glasses-server 6.9.0 → 6.11.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.
@@ -32,6 +32,13 @@ import {
32
32
  isVocabEchoOnly,
33
33
  } from '../lib/hallucination-filter.js'
34
34
  import { dataPath } from '../lib/data-dir.js'
35
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
36
+ import {
37
+ LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
38
+ compressIndexRanges,
39
+ retainedUntilIso,
40
+ type IndexRange,
41
+ } from '../lib/local-first-meetings-contract.js'
35
42
 
36
43
  function ensurePrivateDirectory(path: string): void {
37
44
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -200,6 +207,8 @@ interface TranscriptSession {
200
207
  // Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
201
208
  receivedIndices?: number[]
202
209
  maxChunkIndex?: number
210
+ /** Persisted idle-retention clock. Meeting date/duration still use startTime. */
211
+ lastActivityAt: number
203
212
  // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
204
213
  // any real-content chunk. Used to drop a RUN of echoed brand names while keeping
205
214
  // a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
@@ -207,22 +216,60 @@ interface TranscriptSession {
207
216
  }
208
217
 
209
218
  const sessions = new Map<string, TranscriptSession>()
210
- const CLOSED_SESSION_TTL_MS = 4 * 60 * 60 * 1000
219
+ const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
211
220
  const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
212
221
 
222
+ interface ClosedTranscriptSession {
223
+ closedAt: number
224
+ lastActivityAt: number
225
+ receivedIndices: number[]
226
+ maxChunkIndex: number
227
+ reason: 'saved' | 'expired' | 'closed'
228
+ }
229
+
230
+ const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
231
+
213
232
  // Incremental chunk persistence — survive server restarts
214
233
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
215
234
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)
216
235
 
217
- function readClosedSessions(): Record<string, number> {
236
+ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
218
237
  if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
219
238
  try {
220
239
  const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
221
240
  if (!parsed || typeof parsed !== 'object') return {}
222
- return Object.fromEntries(
223
- Object.entries(parsed as Record<string, unknown>)
224
- .filter(([id, ts]) => /^[A-Za-z0-9:_-]{3,96}$/.test(id) && typeof ts === 'number'),
225
- ) as Record<string, number>
241
+ const normalized: Record<string, ClosedTranscriptSession> = {}
242
+ for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
243
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id)) continue
244
+ if (typeof value === 'number' && Number.isFinite(value)) {
245
+ normalized[id] = {
246
+ closedAt: value,
247
+ lastActivityAt: value,
248
+ receivedIndices: [],
249
+ maxChunkIndex: -1,
250
+ reason: 'closed',
251
+ }
252
+ continue
253
+ }
254
+ if (!value || typeof value !== 'object') continue
255
+ const raw = value as Record<string, unknown>
256
+ const closedAt = typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) ? raw.closedAt : null
257
+ if (closedAt == null) continue
258
+ const lastActivityAt = typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
259
+ ? raw.lastActivityAt
260
+ : closedAt
261
+ const receivedIndices = Array.isArray(raw.receivedIndices)
262
+ ? Array.from(new Set(
263
+ raw.receivedIndices.filter((entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0),
264
+ )).sort((a, b) => a - b)
265
+ : []
266
+ const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
267
+ ? raw.maxChunkIndex
268
+ : (receivedIndices.at(-1) ?? -1)
269
+ const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
270
+ normalized[id] = { closedAt, lastActivityAt, receivedIndices, maxChunkIndex, reason }
271
+ }
272
+ return normalized
226
273
  } catch {
227
274
  try {
228
275
  renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
@@ -234,25 +281,31 @@ function readClosedSessions(): Record<string, number> {
234
281
  function persistClosedSessions(): void {
235
282
  const now = Date.now()
236
283
  const merged = readClosedSessions()
237
- for (const id of deletedSessions) merged[id] = now
238
- for (const [id, closedAt] of Object.entries(merged)) {
239
- if (now - closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
284
+ for (const id of deletedSessions) {
285
+ merged[id] = closedSessionRecords.get(id) ?? {
286
+ closedAt: now,
287
+ lastActivityAt: now,
288
+ receivedIndices: [],
289
+ maxChunkIndex: -1,
290
+ reason: 'closed',
291
+ }
292
+ }
293
+ for (const [id, record] of Object.entries(merged)) {
294
+ if (now - record.closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
240
295
  }
241
296
  try {
242
- const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
243
- writeFileSync(tmp, JSON.stringify(merged, null, 2), { encoding: 'utf-8', mode: 0o600 })
244
- renameSync(tmp, CLOSED_SESSIONS_FILE)
245
- try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
246
- } catch { /* best-effort tombstones */ }
297
+ durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
298
+ } catch { /* best-effort tombstones; saved receipts remain authoritative */ }
247
299
  }
248
300
 
249
301
  function recoverClosedSessions(): void {
250
302
  const now = Date.now()
251
303
  const closed = readClosedSessions()
252
304
  let dirty = false
253
- for (const [id, closedAt] of Object.entries(closed)) {
254
- if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
255
- deletedSessions.add(id)
305
+ for (const [id, record] of Object.entries(closed)) {
306
+ if (now - record.closedAt <= CLOSED_SESSION_TTL_MS) {
307
+ rememberDeletedSession(id)
308
+ closedSessionRecords.set(id, record)
256
309
  } else {
257
310
  delete closed[id]
258
311
  dirty = true
@@ -260,20 +313,17 @@ function recoverClosedSessions(): void {
260
313
  }
261
314
  if (dirty) {
262
315
  try {
263
- const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
264
- writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
265
- renameSync(tmp, CLOSED_SESSIONS_FILE)
266
- try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
316
+ durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(closed, null, 2), { mode: 0o600 })
267
317
  } catch {}
268
318
  }
269
319
  }
270
320
 
271
- /** Persist a session's chunks to disk (called after each new chunk) */
272
- function persistSession(sessionId: string): void {
273
- try {
274
- const session = sessions.get(sessionId)
275
- if (!session) return
276
- const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
321
+ /** Persist a session before acknowledging any chunk. Throws on failure so a
322
+ * client never interprets a non-durable index as accepted. */
323
+ function persistSessionRequired(sessionId: string): void {
324
+ const session = sessions.get(sessionId)
325
+ if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
326
+ const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
277
327
  // chunksIndexed preserves each chunk's original index (a plain filter()
278
328
  // would collapse the sparse array and destroy gap positions on recovery).
279
329
  const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
@@ -281,9 +331,10 @@ function persistSession(sessionId: string): void {
281
331
  const c = session.chunks[i]
282
332
  if (c && c.text) chunksIndexed.push({ i, c })
283
333
  }
284
- const data = JSON.stringify({
334
+ const data = JSON.stringify({
285
335
  sessionId,
286
336
  startTime: session.startTime,
337
+ lastActivityAt: session.lastActivityAt,
287
338
  title: session.title,
288
339
  // `chunks` = legacy dense form, kept for backward compatibility with
289
340
  // existing readers; `chunksIndexed` preserves original indices so gap
@@ -294,9 +345,16 @@ function persistSession(sessionId: string): void {
294
345
  maxChunkIndex: session.maxChunkIndex ?? -1,
295
346
  providerCandidates: session.providerCandidates ?? {},
296
347
  })
297
- writeFileSync(filePath, data, { encoding: 'utf-8', mode: 0o600 })
298
- try { chmodSync(filePath, 0o600) } catch { /* best effort on recovered installs */ }
299
- } catch { /* non-critical — don't break transcription for persistence */ }
348
+ try {
349
+ durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
350
+ } catch (error) {
351
+ console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
352
+ throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
353
+ }
354
+ }
355
+
356
+ function persistSessionBestEffort(sessionId: string): void {
357
+ try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
300
358
  }
301
359
 
302
360
  /** Recover sessions from disk on server restart */
@@ -315,9 +373,14 @@ function recoverSessions(): void {
315
373
  Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
316
374
  const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
317
375
  const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
318
- if (data.sessionId && hasChunks) {
319
- // Only recover sessions less than 4 hours old
320
- if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
376
+ const persistedStat = statSync(resolve(CHUNK_PERSIST_DIR, file))
377
+ const lastActivityAt = typeof data.lastActivityAt === 'number' && Number.isFinite(data.lastActivityAt)
378
+ ? data.lastActivityAt
379
+ : (Number.isFinite(persistedStat.mtimeMs) ? persistedStat.mtimeMs : data.startTime)
380
+ if (data.sessionId && (hasChunks || Array.isArray(data.receivedIndices) || Number.isFinite(lastActivityAt))) {
381
+ // Active retention is idle-based. Long meetings are not purged merely
382
+ // because their original start time is old.
383
+ if (Date.now() - lastActivityAt < LOCAL_FIRST_MEETING_IDLE_RETENTION_MS) {
321
384
  const chunks: TranscriptChunk[] = []
322
385
  if (indexed) {
323
386
  for (const e of indexed) {
@@ -353,6 +416,7 @@ function recoverSessions(): void {
353
416
  chunks,
354
417
  startTime: data.startTime,
355
418
  title: data.title || '',
419
+ lastActivityAt,
356
420
  receivedIndices,
357
421
  maxChunkIndex,
358
422
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
@@ -380,12 +444,32 @@ function recoverSessions(): void {
380
444
  recoveredIds.add(data.sessionId)
381
445
  if (cleaned > 0) {
382
446
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
383
- persistSession(data.sessionId) // re-persist cleaned data to disk
447
+ persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
384
448
  }
385
449
  const gaps = computeGapReport(session).missingIndices.length
386
450
  console.log(`[session-recovery] Recovered ${session.chunks.filter(c => c && c.text).length} chunks for ${data.sessionId}${gaps > 0 ? ` (${gaps} lost-chunk gap${gaps > 1 ? 's' : ''})` : ''}`)
387
451
  } else {
388
- // Stale clean up
452
+ // A stale unsaved session is a real closed state, not a missing
453
+ // session that a late/zombie client may silently recreate. Keep
454
+ // its exact receive ledger for one tombstone horizon after boot.
455
+ const receivedIndices = Array.isArray(data.receivedIndices)
456
+ ? Array.from(new Set(
457
+ (data.receivedIndices as unknown[])
458
+ .filter((value): value is number => Number.isInteger(value) && (value as number) >= 0),
459
+ )).sort((left, right) => left - right)
460
+ : []
461
+ const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
462
+ ? data.maxChunkIndex
463
+ : (receivedIndices.at(-1) ?? -1)
464
+ closedSessionRecords.set(data.sessionId, {
465
+ closedAt: Date.now(),
466
+ lastActivityAt,
467
+ receivedIndices,
468
+ maxChunkIndex,
469
+ reason: 'expired',
470
+ })
471
+ rememberDeletedSession(data.sessionId)
472
+ persistClosedSessions()
389
473
  unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
390
474
  }
391
475
  }
@@ -410,6 +494,12 @@ function recoverSessions(): void {
410
494
  // Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
411
495
  const deletedSessions = new Set<string>()
412
496
  const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
497
+ function rememberDeletedSession(sessionId: string): void {
498
+ deletedSessions.add(sessionId)
499
+ if (deletedSessions.size <= DELETED_SESSION_CAP) return
500
+ const entries = Array.from(deletedSessions)
501
+ for (const id of entries.slice(0, entries.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
502
+ }
413
503
  export function isSessionDeleted(sessionId: string): boolean {
414
504
  return deletedSessions.has(sessionId)
415
505
  }
@@ -418,19 +508,12 @@ export function isSessionDeleted(sessionId: string): boolean {
418
508
  recoverClosedSessions()
419
509
  recoverSessions()
420
510
 
421
- // Auto-cleanup sessions older than 4 hours
511
+ // Auto-cleanup sessions idle for the advertised retention horizon.
422
512
  setInterval(() => {
423
- const cutoff = Date.now() - 4 * 60 * 60 * 1000
513
+ const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
424
514
  for (const [id, session] of sessions) {
425
- if (session.startTime < cutoff) {
426
- sessions.delete(id)
427
- sessionAudioBytes.delete(id)
428
- sessionAudioWrites.delete(id)
429
- clearSessionHallucinationState(id)
430
- // Clean up persisted file too
431
- try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
432
- // Clean up session audio
433
- try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
515
+ if (session.lastActivityAt < cutoff) {
516
+ closeTranscriptSession(id, 'expired')
434
517
  }
435
518
  }
436
519
  // Purge orphaned session-audio dirs (no matching active session)
@@ -493,7 +576,8 @@ setInterval(() => {
493
576
  export function getSession(sessionId: string): TranscriptSession {
494
577
  let session = sessions.get(sessionId)
495
578
  if (!session) {
496
- session = { chunks: [], startTime: Date.now(), title: '', providerCandidates: {} }
579
+ const now = Date.now()
580
+ session = { chunks: [], startTime: now, lastActivityAt: now, title: '', providerCandidates: {} }
497
581
  sessions.set(sessionId, session)
498
582
  }
499
583
  if (!session.providerCandidates) session.providerCandidates = {}
@@ -677,19 +761,81 @@ export function getSessionProviderCandidates(sessionId: string): Record<string,
677
761
  return sessions.get(sessionId)?.providerCandidates ?? {}
678
762
  }
679
763
 
764
+ export interface MeetingSessionStatusSnapshot {
765
+ state: 'active' | 'closed' | 'missing'
766
+ receivedRanges: IndexRange[]
767
+ receivedCount: number
768
+ maxChunkIndex: number
769
+ lastActivityAt: string | null
770
+ retainedUntil: string | null
771
+ }
772
+
773
+ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatusSnapshot {
774
+ const active = sessions.get(sessionId)
775
+ if (active) {
776
+ const received = active.receivedIndices ?? []
777
+ return {
778
+ state: 'active',
779
+ receivedRanges: compressIndexRanges(received),
780
+ receivedCount: received.length,
781
+ maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
782
+ lastActivityAt: new Date(active.lastActivityAt).toISOString(),
783
+ retainedUntil: retainedUntilIso(active.lastActivityAt),
784
+ }
785
+ }
786
+ const closed = closedSessionRecords.get(sessionId)
787
+ if (closed) {
788
+ return {
789
+ state: 'closed',
790
+ receivedRanges: compressIndexRanges(closed.receivedIndices),
791
+ receivedCount: closed.receivedIndices.length,
792
+ maxChunkIndex: closed.maxChunkIndex,
793
+ lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
794
+ retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
795
+ }
796
+ }
797
+ return {
798
+ state: 'missing',
799
+ receivedRanges: [],
800
+ receivedCount: 0,
801
+ maxChunkIndex: -1,
802
+ lastActivityAt: null,
803
+ retainedUntil: null,
804
+ }
805
+ }
806
+
807
+ function closeTranscriptSession(
808
+ sessionId: string,
809
+ reason: ClosedTranscriptSession['reason'],
810
+ options: { preserveAudio?: boolean } = {},
811
+ ): void {
812
+ const session = sessions.get(sessionId)
813
+ const now = Date.now()
814
+ const receivedIndices = [...(session?.receivedIndices ?? [])]
815
+ const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
816
+ closedSessionRecords.set(sessionId, {
817
+ closedAt: now,
818
+ lastActivityAt: session?.lastActivityAt ?? now,
819
+ receivedIndices,
820
+ maxChunkIndex,
821
+ reason,
822
+ })
823
+ finishClosingTranscriptSession(sessionId, options)
824
+ }
825
+
680
826
  /** Delete session after save */
681
827
  export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
828
+ closeTranscriptSession(sessionId, 'saved', options)
829
+ }
830
+
831
+ function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
682
832
  sessions.delete(sessionId)
683
833
  sessionAudioBytes.delete(sessionId)
834
+ sessionAudioWrites.delete(sessionId)
684
835
  // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
685
836
  clearSessionHallucinationState(sessionId)
686
837
  // Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
687
- deletedSessions.add(sessionId)
688
- if (deletedSessions.size > DELETED_SESSION_CAP) {
689
- // Trim oldest entries to prevent unbounded growth
690
- const arr = Array.from(deletedSessions)
691
- for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
692
- }
838
+ rememberDeletedSession(sessionId)
693
839
  persistClosedSessions()
694
840
  // Clean up persisted file
695
841
  try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
@@ -768,14 +914,27 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
768
914
  ensurePrivateDirectory(sessionDir)
769
915
  const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
770
916
  const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
771
- const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
917
+ let currentBytes = sessionAudioBytes.get(sessionId)
918
+ if (currentBytes == null) {
919
+ currentBytes = 0
920
+ try {
921
+ for (const filename of readdirSync(sessionDir)) {
922
+ if (!/^chunk_\d{4}\.wav$/.test(filename)) continue
923
+ currentBytes += statSync(resolve(sessionDir, filename)).size
924
+ }
925
+ } catch (error) {
926
+ throw makeHttpError(503, `meeting audio inventory failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
927
+ }
928
+ }
772
929
  const nextBytes = currentBytes - existingSize + audioBuffer.length
773
- if (nextBytes > MAX_SESSION_AUDIO_BYTES && !existsSync(chunkPath)) {
774
- if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
775
- return
930
+ if (nextBytes > MAX_SESSION_AUDIO_BYTES) {
931
+ throw makeHttpError(507, 'meeting audio capacity exceeded', 'meeting_audio_capacity_exceeded')
932
+ }
933
+ try {
934
+ durableAtomicWriteFileSync(chunkPath, audioBuffer, { mode: 0o600 })
935
+ } catch (error) {
936
+ throw makeHttpError(503, `meeting audio persistence failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
776
937
  }
777
- const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
778
- await trackSessionAudioWrite(sessionId, writeJob)
779
938
  sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
780
939
  }
781
940
 
@@ -995,9 +1154,6 @@ async function processStreamChunk(opts: {
995
1154
  if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
996
1155
  session.startTime = opts.startTimeOverride
997
1156
  }
998
- // Transfer integrity: log this index as delivered before any text filtering,
999
- // so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
1000
- recordReceivedChunk(session, chunkIndex)
1001
1157
  const alreadyCanonical = session.chunks[chunkIndex]
1002
1158
 
1003
1159
  let candidateRecordKey: string | undefined
@@ -1020,21 +1176,24 @@ async function processStreamChunk(opts: {
1020
1176
  // Do not let late duplicate/replayed candidates replace canonical raw audio.
1021
1177
  // Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
1022
1178
  if (alreadyCanonical?.canonical) {
1179
+ session.lastActivityAt = Date.now()
1180
+ recordReceivedChunk(session, chunkIndex)
1023
1181
  if (candidate && candidateRecordKey) {
1024
1182
  session.providerCandidates![candidateRecordKey].accepted =
1025
1183
  alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
1026
1184
  session.providerCandidates![candidateRecordKey].fallbackReason =
1027
1185
  session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
1028
- persistSession(sessionId)
1029
1186
  }
1187
+ persistSessionRequired(sessionId)
1030
1188
  return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
1031
1189
  }
1032
1190
 
1033
1191
  await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
1034
-
1035
- if (candidate) {
1036
- persistSession(sessionId)
1037
- }
1192
+ // Commit the received-index ledger only after the canonical raw WAV is
1193
+ // durable. A failure is typed non-2xx and a retry remains safe.
1194
+ session.lastActivityAt = Date.now()
1195
+ recordReceivedChunk(session, chunkIndex)
1196
+ persistSessionRequired(sessionId)
1038
1197
 
1039
1198
  const pcmData = audioBuffer.subarray(44)
1040
1199
  let sumSq = 0
@@ -1105,8 +1264,8 @@ async function processStreamChunk(opts: {
1105
1264
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
1106
1265
  session.providerCandidates[candidateRecordKey].accepted = false
1107
1266
  session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
1108
- persistSession(sessionId)
1109
1267
  }
1268
+ persistSessionRequired(sessionId)
1110
1269
  return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
1111
1270
  }
1112
1271
 
@@ -1132,8 +1291,9 @@ async function processStreamChunk(opts: {
1132
1291
  finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
1133
1292
  session.providerCandidates[candidateRecordKey].fallbackReason =
1134
1293
  session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
1135
- persistSession(sessionId)
1136
1294
  }
1295
+ session.lastActivityAt = Date.now()
1296
+ persistSessionRequired(sessionId)
1137
1297
  return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
1138
1298
  }
1139
1299
  session.chunks[chunkIndex] = chunk
@@ -1144,7 +1304,8 @@ async function processStreamChunk(opts: {
1144
1304
  }
1145
1305
  }
1146
1306
  const tPersist = performance.now()
1147
- persistSession(sessionId)
1307
+ session.lastActivityAt = Date.now()
1308
+ persistSessionRequired(sessionId)
1148
1309
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
1149
1310
 
1150
1311
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
@@ -1207,7 +1368,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
1207
1368
  : undefined
1208
1369
  if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
1209
1370
  if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
1210
- persistSession(sessionId)
1371
+ session.lastActivityAt = Date.now()
1372
+ persistSessionRequired(sessionId)
1211
1373
  res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
1212
1374
  } catch (err: unknown) {
1213
1375
  sendStreamError(res, err)