@gotcos/glasses-server 6.80.0 → 6.82.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.
@@ -11,6 +11,16 @@ import { createHash } from 'node:crypto'
11
11
  import { Router } from 'express'
12
12
  import { emitDisplay } from '../lib/display-bus.js'
13
13
  import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
14
+ import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
15
+ import {
16
+ AUTONOMOUS_FINALIZE_POLL_MS,
17
+ AUTONOMOUS_FINALIZE_SETTLE_MS,
18
+ createAutonomousFinalizer,
19
+ indicesFromRanges,
20
+ type AutonomousFinalizer,
21
+ type AutonomousSaveOutcome,
22
+ type SessionLedger,
23
+ } from '../lib/meeting-autonomous-finalize.js'
14
24
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
15
25
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
16
26
  import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
@@ -231,10 +241,25 @@ interface MeetingSessionSource {
231
241
  delete(sessionId: string, options?: { preserveAudio?: boolean }): void
232
242
  /** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
233
243
  getState?(sessionId: string): 'active' | 'closed' | 'missing'
244
+ /** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
245
+ getLedger?(sessionId: string): SessionLedger
234
246
  }
235
247
 
236
248
  const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
237
249
 
250
+ /** 6.81.0: the live ledger as the autonomous finalizer reads it (the status route's own numbers). */
251
+ function defaultSessionLedger(sessionId: string): SessionLedger {
252
+ const status = getMeetingSessionStatus(sessionId)
253
+ const lastActivityAt = status.lastActivityAt ? Date.parse(status.lastActivityAt) : NaN
254
+ return {
255
+ state: status.state,
256
+ received: indicesFromRanges(status.receivedRanges),
257
+ asrCompleted: indicesFromRanges(status.asrCompletedRanges),
258
+ maxChunkIndex: status.maxChunkIndex,
259
+ lastActivityAt: Number.isFinite(lastActivityAt) ? lastActivityAt : null,
260
+ }
261
+ }
262
+
238
263
  export interface MeetingRouteDependencies {
239
264
  store?: MeetingStore
240
265
  sessions?: MeetingSessionSource
@@ -252,6 +277,16 @@ export interface MeetingRouteDependencies {
252
277
  stopRequests?: StopRequestStore
253
278
  /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
254
279
  transcriptCleanup?: TranscriptCleanupRunner
280
+ /** 6.81.0: the phone's Stop, durable (lib/meeting-stop-intents.ts). */
281
+ stopIntents?: StopIntentStore
282
+ /** 6.81.0: the server's own save of a stopped meeting. Tests inject the clock, timer and gate. */
283
+ autonomousFinalize?: {
284
+ now?: () => number
285
+ schedule?: (fn: () => void, ms: number) => () => void
286
+ ready?: () => boolean
287
+ log?: (event: string, fields: Record<string, unknown>) => void
288
+ onCreate?: (finalizer: AutonomousFinalizer) => void
289
+ }
255
290
  }
256
291
 
257
292
  const activeFinalizationJobs = new Set<string>()
@@ -584,6 +619,21 @@ function publicDisposition(disposition: CaptureDisposition | null): Record<strin
584
619
  }
585
620
  }
586
621
 
622
+ /** 6.81.0: the Stop the server holds, as the status and stop routes show it. Never a path. */
623
+ function publicStopIntent(intent: StopIntent): Record<string, unknown> {
624
+ return {
625
+ finalChunkIndex: intent.finalChunkIndex,
626
+ state: intent.state,
627
+ receivedAt: new Date(intent.receivedAt).toISOString(),
628
+ savedBy: intent.savedBy ?? null,
629
+ savedAt: intent.savedAt ? new Date(intent.savedAt).toISOString() : null,
630
+ reason: intent.reason ?? null,
631
+ waitingFor: intent.state === 'waiting' && intent.lastWait
632
+ ? { reason: intent.lastWait.reason, missingCount: intent.lastWait.missingCount, missingSample: intent.lastWait.missingSample }
633
+ : null,
634
+ }
635
+ }
636
+
587
637
  function suppliedServerPin(
588
638
  req: { get: (name: string) => string | undefined },
589
639
  fallback: unknown,
@@ -608,6 +658,83 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
608
658
  const stopRequests = deps.stopRequests ?? createStopRequestStore()
609
659
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
610
660
 
661
+ // ── 6.81.0: the server finishes a stopped meeting on its own ─────────────────────
662
+ // lib/meeting-stop-intents.ts says what a Stop intent is; lib/meeting-autonomous-
663
+ // finalize.ts says when the server may save (only a complete capture, never early).
664
+ const stopIntents = deps.stopIntents ?? getStopIntentStore()
665
+ const autoDeps = deps.autonomousFinalize ?? {}
666
+ const autoNow = autoDeps.now ?? Date.now
667
+ const ledgerFor = (sessionId: string): SessionLedger => (sessions.getLedger ?? defaultSessionLedger)(sessionId)
668
+ const autoLog = autoDeps.log ?? ((event: string, fields: Record<string, unknown>) => {
669
+ const line = `[meeting/auto-save] ${event} ${String(fields.sessionId ?? '')} ${JSON.stringify(fields)}`
670
+ if (event === 'finalize_failed' || event === 'revoked' || event === 'late_chunk_refused') console.warn(line)
671
+ else console.log(line)
672
+ appendAutonomousSaveLog(event, fields)
673
+ })
674
+
675
+ /** Someone saved a meeting the phone had told us about before the server did: theirs, not ours. */
676
+ const notePhoneSavedFirst = (sessionId: string, savedBy: 'phone' | 'other') => {
677
+ const intent = stopIntents.get(sessionId)
678
+ if (!intent || intent.state !== 'waiting') return
679
+ const at = autoNow()
680
+ stopIntents.update(sessionId, { state: 'saved', savedBy, savedAt: at, lastWait: null })
681
+ autoLog(savedBy === 'phone' ? 'phone_saved' : 'client_saved', { sessionId, waitedMs: at - intent.receivedAt, finalChunkIndex: intent.finalChunkIndex })
682
+ }
683
+ /** The phone came back after the server saved on its own. Logged once, with how it showed up. */
684
+ const notePhoneAfterAutonomousSave = (sessionId: string, via: string) => {
685
+ const intent = stopIntents.get(sessionId)
686
+ if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || intent.phoneConfirmedAt) return
687
+ const at = autoNow()
688
+ stopIntents.update(sessionId, { phoneConfirmedAt: at, phoneConfirmedBy: via })
689
+ autoLog('phone_confirmed', { sessionId, via, sinceSaveMs: at - (intent.savedAt ?? at), lateReplays: intent.lateReplays ?? 0, lateRefused: intent.lateRefused ?? 0 })
690
+ }
691
+
692
+ const autonomousSave = async (intent: StopIntent): Promise<AutonomousSaveOutcome> => {
693
+ // The phone pinned one server instance. A different one (a reinstall) is not its Mac.
694
+ if (intent.serverInstanceId !== getServerInstanceId()) return { ok: false, status: 409, reason: 'server_instance_mismatch' }
695
+ const entries = sessions.getChunkEntries(intent.sessionId) ?? []
696
+ const canonicalCount = entries.filter(entry => entry?.chunk?.text).length
697
+ const integrity = sessions.getIntegrity(intent.sessionId)
698
+ let status = 0
699
+ let payload: Record<string, unknown> = {}
700
+ const saved = await performSave(
701
+ intent.sessionId,
702
+ { ...(intent.title ? { title: intent.title } : {}), ...(intent.domain ? { domain: intent.domain } : {}) },
703
+ (code, body) => { status = code; payload = body },
704
+ 'server',
705
+ )
706
+ if (!saved || status !== 200) return { ok: false, status, reason: String(payload.reason ?? 'meeting_save_error') }
707
+ // Someone saved it between the decision and the lock: theirs, not ours.
708
+ if (payload.replayed === true) return { ok: false, status: 200, reason: 'already_saved' }
709
+ return {
710
+ ok: true,
711
+ filename: saved.filename,
712
+ sidecarPath: saved.sidecarPath,
713
+ chunkCount: intent.finalChunkIndex + 1,
714
+ canonicalCount,
715
+ missingIndices: integrity?.missingIndices ?? [],
716
+ }
717
+ }
718
+
719
+ const autonomousFinalizer = createAutonomousFinalizer({
720
+ waiting: () => stopIntents.waiting(),
721
+ get: sessionId => stopIntents.get(sessionId),
722
+ update: (sessionId, patch) => { stopIntents.update(sessionId, patch) },
723
+ ledger: ledgerFor,
724
+ alreadySaved: sessionId => store.findBySessionId(sessionId) !== null,
725
+ saving: sessionId => savingSessions.has(sessionId),
726
+ save: autonomousSave,
727
+ ready: autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()),
728
+ now: autoNow,
729
+ schedule: autoDeps.schedule ?? ((fn, ms) => {
730
+ const timer = setTimeout(fn, ms)
731
+ timer.unref?.()
732
+ return () => clearTimeout(timer)
733
+ }),
734
+ log: autoLog,
735
+ })
736
+ autoDeps.onCreate?.(autonomousFinalizer)
737
+
611
738
  let recoveryPendingLogged = false
612
739
  router.get('/meeting/sessions/:sessionId/status', (req, res) => {
613
740
  const sessionId = String(req.params.sessionId ?? '')
@@ -651,6 +778,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
651
778
  // A saved meeting or a live session is the truth for itself; the quarantine is
652
779
  // read only when neither is.
653
780
  const disposition = state === 'saved' || state === 'active' ? null : readCaptureDisposition(sessionId)
781
+ // 6.81.0: a pinned status read is the phone; one after the server saved on its own is its return.
782
+ if (saved && pin) notePhoneAfterAutonomousSave(sessionId, 'status')
783
+ const intent = stopIntents.get(sessionId)
654
784
  res.set('Cache-Control', 'private, no-store')
655
785
  res.json({
656
786
  sessionId,
@@ -670,6 +800,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
670
800
  // (null otherwise). Older clients ignore both.
671
801
  outcome: captureOutcome(state, disposition),
672
802
  disposition: publicDisposition(disposition),
803
+ // 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
804
+ stopIntent: intent ? publicStopIntent(intent) : null,
673
805
  })
674
806
  })
675
807
 
@@ -781,43 +913,124 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
781
913
  res.json(stopRequestBody(request, savingSessions.has(sessionId) ? { finalizing: true } : {}))
782
914
  })
783
915
 
784
- router.post('/meeting/save', async (req, res) => {
916
+ // ── 6.81.0: the phone's Stop ──────────────────────────────────────────────────────
917
+ // POST /meeting/sessions/:id/stop {finalChunkIndex, serverInstanceId}. One small fetch the
918
+ // phone can send the moment Stop is pressed, before any IndexedDB work, so a phone that is
919
+ // off screen still tells the Mac. It never closes the session and never refuses a chunk:
920
+ // the server saves on its own only once it holds every chunk 0..finalChunkIndex.
921
+ router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
922
+ const sessionId = String(req.params.sessionId ?? '')
923
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
924
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
925
+ return
926
+ }
927
+ if (MAC_CAPTURE_ID.test(sessionId)) {
928
+ res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
929
+ return
930
+ }
931
+ const body = req.body as Record<string, unknown> | undefined
932
+ const finalChunkIndex = body?.finalChunkIndex
933
+ if (!Number.isInteger(finalChunkIndex) || (finalChunkIndex as number) < -1 || (finalChunkIndex as number) > 1_000_000) {
934
+ res.status(400).json({ error: 'finalChunkIndex must be a whole number, -1 or more', reason: 'invalid_final_chunk_index' })
935
+ return
936
+ }
937
+ if ((body?.title !== undefined && typeof body.title !== 'string') || (body?.domain !== undefined && typeof body.domain !== 'string')) {
938
+ res.status(400).json({ error: 'title and domain must be strings', reason: 'invalid_metadata' })
939
+ return
940
+ }
941
+ const serverInstanceId = getServerInstanceId()
942
+ if (!serverInstanceId) {
943
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
944
+ return
945
+ }
946
+ // Required, not optional: only a phone pinned to THIS Mac may hand it a Stop.
947
+ const pin = suppliedServerPin(req, body?.serverInstanceId)
948
+ if (!pin) {
949
+ res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
950
+ return
951
+ }
952
+ if (pin !== serverInstanceId) {
953
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
954
+ return
955
+ }
956
+ res.set('Cache-Control', 'private, no-store')
957
+ const saved = store.findBySessionId(sessionId)
958
+ if (saved) {
959
+ notePhoneAfterAutonomousSave(sessionId, 'stop_retry')
960
+ res.json({ sessionId, state: 'saved', saveReceipt: publicSaveResponse(saved, true) })
961
+ return
962
+ }
963
+ const ready = (autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()))()
964
+ // Before recovery has run the ledger is empty by construction, so it proves nothing:
965
+ // record the Stop and let the finalizer judge it once the gate opens.
966
+ const ledger = ready ? ledgerFor(sessionId) : null
967
+ if (ledger?.state === 'closed') {
968
+ res.status(409).json({ error: 'This meeting is closed without a save', reason: 'not_live', state: 'closed' })
969
+ return
970
+ }
971
+ if (ledger && ledger.maxChunkIndex > (finalChunkIndex as number)) {
972
+ // The Mac holds audio past the phone's last chunk. Never take that Stop.
973
+ res.status(409).json({ error: 'The Mac holds chunks past finalChunkIndex', reason: 'final_index_behind_server', maxChunkIndex: ledger.maxChunkIndex })
974
+ return
975
+ }
976
+ const source = typeof body?.source === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(body.source) ? body.source : 'companion'
977
+ let result: ReturnType<StopIntentStore['record']>
978
+ try {
979
+ result = stopIntents.record({
980
+ sessionId,
981
+ finalChunkIndex: finalChunkIndex as number,
982
+ source,
983
+ ...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.slice(0, 160) } : {}),
984
+ ...(typeof body?.domain === 'string' && body.domain.trim() ? { domain: body.domain } : {}),
985
+ serverInstanceId,
986
+ })
987
+ } catch (error) {
988
+ // Not durable means not received: the phone must keep its own Stop path.
989
+ console.error(`[meeting/auto-save] Could not record the Stop for ${sessionId}: ${error instanceof Error ? error.message : error}`)
990
+ res.status(503).json({ error: 'Stop could not be recorded; retry', reason: 'stop_persistence_failed', retryable: true })
991
+ return
992
+ }
993
+ if (!result.ok) {
994
+ res.json({ sessionId, state: 'saved', stopIntent: publicStopIntent(result.intent) })
995
+ return
996
+ }
997
+ const intent = result.intent
998
+ if (result.created || result.raised) {
999
+ autoLog(result.created ? 'stop_received' : 'stop_raised', {
1000
+ sessionId, source, finalChunkIndex: intent.finalChunkIndex, posts: intent.posts,
1001
+ received: ledger?.received.length ?? null, replaced: intent.replaced ?? null,
1002
+ })
1003
+ }
1004
+ void autonomousFinalizer.tick('stop_received')
1005
+ res.status(202).json({
1006
+ sessionId,
1007
+ state: 'stop_received',
1008
+ finalChunkIndex: intent.finalChunkIndex,
1009
+ receivedAt: new Date(intent.receivedAt).toISOString(),
1010
+ posts: intent.posts,
1011
+ autonomousSave: { settleMs: AUTONOMOUS_FINALIZE_SETTLE_MS, pollMs: AUTONOMOUS_FINALIZE_POLL_MS },
1012
+ })
1013
+ })
1014
+
1015
+ /**
1016
+ * The save itself, shared by the phone's POST /meeting/save and the server's own save of a
1017
+ * stopped meeting (6.81.0). One body so the two can never drift: the same lock, the same
1018
+ * idempotent receipt, the same durable sidecar-then-job order, and the same maintenance
1019
+ * lease order (the finalization job's lease is taken before the save lease is released).
1020
+ *
1021
+ * `respond` is called exactly once, before any background work is scheduled, as the route
1022
+ * always did. `origin` changes only bookkeeping: who is recorded as having saved, and the
1023
+ * phone-confirmed line when the phone replays a save the server already made.
1024
+ */
1025
+ async function performSave(
1026
+ sessionId: string,
1027
+ body: Record<string, unknown> | undefined,
1028
+ respond: (status: number, payload: Record<string, unknown>, noStore?: boolean) => void,
1029
+ origin: 'phone' | 'client' | 'server',
1030
+ ): Promise<SavedMeeting | null> {
785
1031
  let lockedSessionId: string | null = null
786
1032
  let maintenanceLease: MaintenanceWorkLease | undefined
787
1033
  try {
788
- const body = req.body as Record<string, unknown> | undefined
789
- const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
790
- if (!sessionId) {
791
- res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
792
- return
793
- }
794
- if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
795
- res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
796
- return
797
- }
798
- if (body?.title !== undefined && typeof body.title !== 'string') {
799
- res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
800
- return
801
- }
802
- if (body?.domain !== undefined && typeof body.domain !== 'string') {
803
- res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
804
- return
805
- }
806
- const serverInstanceId = getServerInstanceId()
807
- if (!serverInstanceId) {
808
- res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
809
- return
810
- }
811
- const pin = suppliedServerPin(req, body?.serverInstanceId)
812
- if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
813
- res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
814
- return
815
- }
816
- if (pin && pin !== serverInstanceId) {
817
- res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
818
- return
819
- }
820
-
821
1034
  // A response can be lost after both durable files were committed. Find
822
1035
  // the sidecar by session ID so client retry/restart is idempotent.
823
1036
  const alreadySaved = store.findBySessionId(sessionId)
@@ -833,16 +1046,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
833
1046
  sessions,
834
1047
  finalDiarize,
835
1048
  })
836
- res.set('Cache-Control', 'private, no-store')
837
- res.json(publicSaveResponse(alreadySaved, true))
838
- return
1049
+ if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
1050
+ respond(200, publicSaveResponse(alreadySaved, true), true)
1051
+ return alreadySaved
839
1052
  }
840
1053
  if (MAC_CAPTURE_ID.test(sessionId)) {
841
1054
  requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
842
1055
  }
843
1056
  if (savingSessions.has(sessionId)) {
844
- res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
845
- return
1057
+ respond(409, { error: 'Meeting save already in progress', reason: 'save_in_progress' })
1058
+ return null
846
1059
  }
847
1060
  // Saving/finalizing an already-recording session is a drain
848
1061
  // continuation. It must remain admitted so existing audio can reach a
@@ -858,10 +1071,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
858
1071
  // lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
859
1072
  // Still 404, so every existing client reads it exactly as before.
860
1073
  const known = (sessions.getState ?? defaultSessionState)(sessionId)
861
- res.status(404).json(known === 'active' || known === 'closed'
1074
+ respond(404, known === 'active' || known === 'closed'
862
1075
  ? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
863
1076
  : { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
864
- return
1077
+ return null
865
1078
  }
866
1079
 
867
1080
  const chunks = sessions.getChunks(sessionId) ?? []
@@ -913,6 +1126,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
913
1126
  finalizationRequired,
914
1127
  claimPending,
915
1128
  })
1129
+ // 6.81.0: who saved. A pinned save is the phone's; an unpinned one (the stranded sweep's
1130
+ // own POST, a manual curl) is someone else's. Either closes a waiting Stop. The server's
1131
+ // own save is recorded by its finalizer.
1132
+ if (origin !== 'server') notePhoneSavedFirst(sessionId, origin === 'phone' ? 'phone' : 'other')
916
1133
 
917
1134
  // Two-phase replay intent: the canonical sidecar above is the recovery
918
1135
  // source of truth, and this indexed job is its fast execution ledger.
@@ -965,6 +1182,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
965
1182
  filename: saved.filename,
966
1183
  durationMin: saved.durationMin,
967
1184
  domain: saved.domain,
1185
+ // 6.81.0, additive: the server saved this on its own after the phone's Stop.
1186
+ ...(origin === 'server' ? { savedBy: 'server' } : {}),
968
1187
  },
969
1188
  })
970
1189
  } catch (error) {
@@ -994,8 +1213,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
994
1213
  finalizationJob = null
995
1214
  }
996
1215
 
997
- res.set('Cache-Control', 'private, no-store')
998
- res.json(publicSaveResponse(saved))
1216
+ respond(200, publicSaveResponse(saved), true)
999
1217
 
1000
1218
  if (finalizationJob) {
1001
1219
  scheduleFinalizationJob(finalizationJob, {
@@ -1007,17 +1225,68 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1007
1225
  transcriptCleanup,
1008
1226
  })
1009
1227
  }
1228
+ return saved
1010
1229
  } catch (error) {
1011
1230
  if (error instanceof MeetingStoreError) {
1012
- res.status(error.status).json({ error: error.message, reason: error.code })
1013
- return
1231
+ respond(error.status, { error: error.message, reason: error.code })
1232
+ return null
1014
1233
  }
1015
1234
  console.error('[meeting/save] Finalization failed:', error)
1016
- res.status(500).json({ error: 'Meeting save failed', reason: 'meeting_save_error' })
1235
+ respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
1236
+ return null
1017
1237
  } finally {
1018
1238
  maintenanceLease?.release()
1019
1239
  if (lockedSessionId) savingSessions.delete(lockedSessionId)
1020
1240
  }
1241
+ }
1242
+
1243
+ router.post('/meeting/save', async (req, res) => {
1244
+ let responded = false
1245
+ const respond = (status: number, payload: Record<string, unknown>, noStore = false) => {
1246
+ if (responded) return
1247
+ responded = true
1248
+ if (noStore) res.set('Cache-Control', 'private, no-store')
1249
+ res.status(status).json(payload)
1250
+ }
1251
+ try {
1252
+ const body = req.body as Record<string, unknown> | undefined
1253
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
1254
+ if (!sessionId) {
1255
+ res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
1256
+ return
1257
+ }
1258
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1259
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1260
+ return
1261
+ }
1262
+ if (body?.title !== undefined && typeof body.title !== 'string') {
1263
+ res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
1264
+ return
1265
+ }
1266
+ if (body?.domain !== undefined && typeof body.domain !== 'string') {
1267
+ res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
1268
+ return
1269
+ }
1270
+ const serverInstanceId = getServerInstanceId()
1271
+ if (!serverInstanceId) {
1272
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
1273
+ return
1274
+ }
1275
+ const pin = suppliedServerPin(req, body?.serverInstanceId)
1276
+ if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
1277
+ res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
1278
+ return
1279
+ }
1280
+ if (pin && pin !== serverInstanceId) {
1281
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
1282
+ return
1283
+ }
1284
+ // Only the phone pins its saves (the local-first contract); the stranded sweep does not.
1285
+ await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
1286
+ } catch (error) {
1287
+ console.error('[meeting/save] Finalization failed:', error)
1288
+ respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
1289
+ }
1021
1290
  })
1022
1291
 
1023
1292
  // ── Record identity for the speaker surfaces (6.47.0) ─────────────────
@@ -2669,7 +2938,17 @@ async function finalizeBatch(options: {
2669
2938
  }
2670
2939
  }
2671
2940
 
2672
- export const meetingRouter = createMeetingRouter()
2941
+ let defaultAutonomousFinalizer: AutonomousFinalizer | null = null
2942
+ export const meetingRouter = createMeetingRouter({ autonomousFinalize: { onCreate: finalizer => { defaultAutonomousFinalizer = finalizer } } })
2943
+
2944
+ /**
2945
+ * 6.81.0: at boot (once admissions open), pick up every Stop the server was holding when it
2946
+ * last exited and check it again. A Stop is durable, so a restart between the Stop and the
2947
+ * last chunk loses nothing: the finalizer waits for recovery, then for the chunks.
2948
+ */
2949
+ export function resumeAutonomousMeetingSaves(): void {
2950
+ void defaultAutonomousFinalizer?.tick('boot')
2951
+ }
2673
2952
 
2674
2953
  /** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
2675
2954
  export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
@@ -0,0 +1,101 @@
1
+ // 6.82.0 Routines: every routine on this server, the morning brief first.
2
+ //
3
+ // GET /api/routines list (the brief, then skill routines) + master switch + limits
4
+ // GET /api/routines/skills the workspace skills a routine may run (labels only, never paths)
5
+ // GET /api/routines/runs recent runs of every skill routine
6
+ // POST /api/routines create a skill routine (201; 400 bad field; 409 at the limit)
7
+ // GET /api/routines/:id one routine
8
+ // PATCH /api/routines/:id edit (PUT is the same); `resetBreaker: true` closes an open breaker
9
+ // POST /api/routines/:id/pause turn it off
10
+ // POST /api/routines/:id/resume turn it on
11
+ // DELETE /api/routines/:id delete a skill routine (the brief can only be turned off)
12
+ // POST /api/routines/:id/run Run now (202; 409 running or off; 423 breaker open; 429 capped)
13
+ // GET /api/routines/:id/runs that routine's run history
14
+ //
15
+ // Authenticated by the global /api middleware like every settings route. No route returns a run's
16
+ // full answer: it lands in the conversation store like any reply.
17
+
18
+ import { Router, type Response } from 'express'
19
+ import { MORNING_BRIEF_ROUTINE_ID } from '../lib/morning-brief-scheduler.js'
20
+ import { RoutineRegistryError, type RoutineRegistry } from '../lib/routines-registry.js'
21
+ import type { WorkspaceSkill } from '../lib/workspace-skills.js'
22
+
23
+ const ID_RE = /^(morning-brief|routine-[0-9a-f]{10})$/
24
+
25
+ export function createRoutinesRouter(registry: () => RoutineRegistry, skills: () => WorkspaceSkill[]): Router {
26
+ const router = Router()
27
+
28
+ const fail = (res: Response, error: unknown, fallback: string) => {
29
+ if (error instanceof RoutineRegistryError) {
30
+ return res.status(error.status).json({ error: { code: error.code, message: error.message, ...error.detail } })
31
+ }
32
+ console.error(`[routines] ${fallback}:`, error)
33
+ return res.status(500).json({ error: { code: fallback, message: 'The routine could not be read or saved.' } })
34
+ }
35
+ const idOf = (raw: string, res: Response): string | null => {
36
+ if (ID_RE.test(raw)) return raw
37
+ res.status(404).json({ error: { code: 'routine_not_found', message: 'That routine does not exist.' } })
38
+ return null
39
+ }
40
+
41
+ router.get('/routines', async (_req, res) => {
42
+ try { res.json(await registry().list()) } catch (error) { fail(res, error, 'routines_unavailable') }
43
+ })
44
+
45
+ router.get('/routines/skills', (_req, res) => {
46
+ res.set('Cache-Control', 'private, no-store')
47
+ try {
48
+ res.json({ skills: skills().map(s => ({ name: s.name, slash: s.slash, description: s.description, group: s.group })) })
49
+ } catch (error) { fail(res, error, 'skills_unavailable') }
50
+ })
51
+
52
+ router.get('/routines/runs', async (req, res) => {
53
+ const limit = Number.parseInt(String(req.query.limit ?? '20'), 10)
54
+ try { res.json({ runs: await registry().runs(undefined, Number.isFinite(limit) ? limit : 20) }) } catch (error) { fail(res, error, 'routines_unavailable') }
55
+ })
56
+
57
+ router.post('/routines', async (req, res) => {
58
+ try { res.status(201).json({ routine: await registry().create(req.body) }) } catch (error) { fail(res, error, 'routine_save_failed') }
59
+ })
60
+
61
+ router.get('/routines/:id', async (req, res) => {
62
+ const id = idOf(req.params.id, res); if (!id) return
63
+ try { res.json({ routine: await registry().get(id) }) } catch (error) { fail(res, error, 'routines_unavailable') }
64
+ })
65
+
66
+ const edit = async (req: { params: { id: string }; body: unknown }, res: Response) => {
67
+ const id = idOf(req.params.id, res); if (!id) return
68
+ try { res.json({ routine: await registry().update(id, req.body) }) } catch (error) { fail(res, error, 'routine_save_failed') }
69
+ }
70
+ router.patch('/routines/:id', edit)
71
+ router.put('/routines/:id', edit)
72
+
73
+ router.post('/routines/:id/pause', async (req, res) => {
74
+ const id = idOf(req.params.id, res); if (!id) return
75
+ try { res.json({ routine: await registry().setEnabled(id, false) }) } catch (error) { fail(res, error, 'routine_save_failed') }
76
+ })
77
+ router.post('/routines/:id/resume', async (req, res) => {
78
+ const id = idOf(req.params.id, res); if (!id) return
79
+ try { res.json({ routine: await registry().setEnabled(id, true) }) } catch (error) { fail(res, error, 'routine_save_failed') }
80
+ })
81
+
82
+ router.delete('/routines/:id', async (req, res) => {
83
+ const id = idOf(req.params.id, res); if (!id) return
84
+ try { await registry().remove(id); res.status(204).end() } catch (error) { fail(res, error, 'routine_save_failed') }
85
+ })
86
+
87
+ router.post('/routines/:id/run', async (req, res) => {
88
+ const id = idOf(req.params.id, res); if (!id) return
89
+ try { res.status(202).json({ run: await registry().runNow(id) }) } catch (error) { fail(res, error, 'routine_run_failed') }
90
+ })
91
+
92
+ router.get('/routines/:id/runs', async (req, res) => {
93
+ const id = idOf(req.params.id, res); if (!id) return
94
+ const limit = Number.parseInt(String(req.query.limit ?? '14'), 10)
95
+ try { res.json({ runs: await registry().runs(id, Number.isFinite(limit) ? limit : 14) }) } catch (error) { fail(res, error, 'routines_unavailable') }
96
+ })
97
+
98
+ return router
99
+ }
100
+
101
+ export { MORNING_BRIEF_ROUTINE_ID }
@@ -1,5 +1,6 @@
1
1
  import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
2
2
  import { getMeetingStore } from '../lib/meeting-store.js'
3
+ import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
3
4
  import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
4
5
  import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
5
6
  import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
@@ -2164,6 +2165,47 @@ interface StreamCandidateInput {
2164
2165
  mode?: string
2165
2166
  }
2166
2167
 
2168
+ /**
2169
+ * 6.81.0: the phone re-sends a chunk after the server saved its meeting on its own.
2170
+ *
2171
+ * WHY. The server saves a stopped meeting itself only once it holds every chunk, so any chunk
2172
+ * the phone still lists as unconfirmed is one whose upload reached the Mac and whose answer
2173
+ * was lost while the phone was off screen. Before 6.81.0 such a replay was refused 410 and the
2174
+ * phone, holding a closed meeting with "unconfirmed" audio, called it a collision and kept the
2175
+ * audio forever. The saved meeting already holds that chunk, so it answers with the saved
2176
+ * text, exactly as a live session answers a replayed index, and the phone adopts the receipt.
2177
+ *
2178
+ * ONLY for meetings the SERVER saved after a Stop: every other closed session answers 410 as
2179
+ * before. A chunk the saved meeting never held is still refused, and is logged loudly, because
2180
+ * the finalizer's rule (never save an incomplete capture) says it cannot happen.
2181
+ */
2182
+ function replayAfterAutonomousSave(sessionId: string, chunkIndex: number): StreamChunkCompletionResponse | null {
2183
+ let intent: StopIntent | null
2184
+ try { intent = getStopIntentStore().get(sessionId) } catch { return null }
2185
+ if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || !intent.sidecarPath) return null
2186
+ const closed = closedSessionRecords.get(sessionId)
2187
+ const replay = savedChunkReplay(intent.sidecarPath, chunkIndex, closed?.asrCompletedIndices ?? [])
2188
+ const store = getStopIntentStore()
2189
+ if (!replay) {
2190
+ store.update(sessionId, { lateRefused: (intent.lateRefused ?? 0) + 1 })
2191
+ const fields = { sessionId, chunkIndex, finalChunkIndex: intent.finalChunkIndex, lateRefused: (intent.lateRefused ?? 0) + 1 }
2192
+ console.warn(`[meeting/auto-save] late_chunk_refused ${sessionId} ${JSON.stringify(fields)}`)
2193
+ appendAutonomousSaveLog('late_chunk_refused', fields)
2194
+ return null
2195
+ }
2196
+ const at = Date.now()
2197
+ store.update(sessionId, {
2198
+ lateReplays: (intent.lateReplays ?? 0) + 1,
2199
+ ...(intent.phoneConfirmedAt ? {} : { phoneConfirmedAt: at, phoneConfirmedBy: 'chunk_replay' }),
2200
+ })
2201
+ const fields = { sessionId, chunkIndex, kind: replay.kind, lateReplays: (intent.lateReplays ?? 0) + 1, sinceSaveMs: at - (intent.savedAt ?? at) }
2202
+ console.log(`[meeting/auto-save] late_replay ${sessionId} ${JSON.stringify(fields)}`)
2203
+ appendAutonomousSaveLog('late_replay', fields)
2204
+ if (!intent.phoneConfirmedAt) appendAutonomousSaveLog('phone_confirmed', { sessionId, via: 'chunk_replay', sinceSaveMs: at - (intent.savedAt ?? at) })
2205
+ if (replay.kind === 'canonical') return canonicalChunkResponse(replay.chunk as unknown as TranscriptChunk, sessionId, chunkIndex)
2206
+ return emptyChunkResponse({ text: '', speaker: 'Ext', elapsed: 0, canonical: false }, sessionId, chunkIndex)
2207
+ }
2208
+
2167
2209
  async function processStreamChunk(opts: {
2168
2210
  sessionId: string
2169
2211
  chunkIndex: number
@@ -2182,7 +2224,12 @@ async function processStreamChunk(opts: {
2182
2224
  const tReq = performance.now()
2183
2225
  validateSessionId(sessionId)
2184
2226
  validateChunkIndex(chunkIndex)
2185
- if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
2227
+ if (isSessionDeleted(sessionId)) {
2228
+ // 6.81.0: a meeting the server saved on its own answers a chunk it already holds.
2229
+ const replay = replayAfterAutonomousSave(sessionId, chunkIndex)
2230
+ if (replay) return replay
2231
+ throw makeHttpError(410, 'session is closed', 'session_closed')
2232
+ }
2186
2233
  if (audioBuffer.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
2187
2234
 
2188
2235
  const audioSha256 = sha256Hex(audioBuffer)