@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.
- package/CHANGELOG.md +24 -0
- package/package.json +4 -1
- package/server/index.ts +10 -1
- package/server/lib/claude-bridge.ts +6 -1
- package/server/lib/codex-bridge.ts +1 -1
- package/server/lib/cursor-bridge.ts +1 -1
- package/server/lib/direct-bridge.ts +1 -1
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/morning-brief-schedule.ts +13 -7
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/routines-config.ts +421 -0
- package/server/lib/routines-registry.ts +151 -0
- package/server/lib/routines-runtime.ts +64 -0
- package/server/lib/routines-scheduler.ts +509 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/routines.ts +101 -0
- package/server/routes/transcribe-stream.ts +48 -1
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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)
|