@gotcos/glasses-server 6.21.7 → 6.21.9
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/.env.example +14 -0
- package/CHANGELOG.md +23 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/batch-transcript-quality.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +23 -6
- package/server/lib/g2-ops-handoff.ts +193 -32
- package/server/lib/meeting-batch-transcribe.ts +554 -10
- package/server/lib/meeting-finalization-jobs.ts +235 -0
- package/server/lib/meeting-store.ts +9 -0
- package/server/lib/whisper-local.ts +77 -6
- package/server/routes/health.ts +15 -0
- package/server/routes/meeting.ts +268 -52
- package/server/routes/transcribe-stream.ts +67 -2
package/server/routes/meeting.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
3
3
|
// are durable before the session is closed; batch improvement runs afterward.
|
|
4
4
|
|
|
5
|
-
import { readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
5
|
+
import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { Router } from 'express'
|
|
8
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
enqueueSerializedHqWork,
|
|
29
29
|
runMeetingBatchPipeline,
|
|
30
30
|
segmentTranscriptChunks,
|
|
31
|
+
stopProgressiveHqSession,
|
|
31
32
|
transcribeSegments,
|
|
32
33
|
} from '../lib/meeting-batch-transcribe.js'
|
|
33
34
|
import {
|
|
@@ -63,8 +64,23 @@ import {
|
|
|
63
64
|
type TranscriptGapReport,
|
|
64
65
|
} from './transcribe-stream.js'
|
|
65
66
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
66
|
-
import {
|
|
67
|
-
|
|
67
|
+
import {
|
|
68
|
+
acquireMaintenanceWork,
|
|
69
|
+
maintenanceAdmissionsOpen,
|
|
70
|
+
type MaintenanceWorkLease,
|
|
71
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
72
|
+
import {
|
|
73
|
+
claimMeetingInOperations,
|
|
74
|
+
earlyMeetingSyncEnabled,
|
|
75
|
+
handoffMeetingToOperations,
|
|
76
|
+
} from '../lib/g2-ops-handoff.js'
|
|
77
|
+
import {
|
|
78
|
+
canonicalFinalizationIsComplete,
|
|
79
|
+
MeetingFinalizationJobStore,
|
|
80
|
+
markCanonicalFinalizationState,
|
|
81
|
+
readFinalizationChunkEntries,
|
|
82
|
+
type MeetingFinalizationJob,
|
|
83
|
+
} from '../lib/meeting-finalization-jobs.js'
|
|
68
84
|
|
|
69
85
|
function cosOpsPipelineConfigured(): boolean {
|
|
70
86
|
// Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
|
|
@@ -92,9 +108,195 @@ export interface MeetingRouteDependencies {
|
|
|
92
108
|
audioDir: string,
|
|
93
109
|
entries: IndexedTranscriptChunk[],
|
|
94
110
|
streamingWordCount: number,
|
|
111
|
+
sessionId?: string,
|
|
95
112
|
) => Promise<BatchTranscription>
|
|
96
113
|
scheduleBackground?: (task: Promise<void>) => void
|
|
97
114
|
emit?: typeof emitDisplay
|
|
115
|
+
finalizationJobs?: MeetingFinalizationJobStore
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const activeFinalizationJobs = new Set<string>()
|
|
119
|
+
const finalizationRetryCounts = new Map<string, number>()
|
|
120
|
+
|
|
121
|
+
interface FinalizationRuntime {
|
|
122
|
+
finalizationJobs: MeetingFinalizationJobStore
|
|
123
|
+
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
124
|
+
scheduleBackground: (task: Promise<void>) => void
|
|
125
|
+
sessions?: MeetingSessionSource
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
|
|
129
|
+
const entries: IndexedTranscriptChunk[] = []
|
|
130
|
+
for (const item of raw) {
|
|
131
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
|
132
|
+
const candidate = item as { chunkIndex?: unknown; chunk?: unknown }
|
|
133
|
+
if (!Number.isInteger(candidate.chunkIndex) || (candidate.chunkIndex as number) < 0) return null
|
|
134
|
+
if (!candidate.chunk || typeof candidate.chunk !== 'object' || Array.isArray(candidate.chunk)) return null
|
|
135
|
+
const chunk = candidate.chunk as Partial<TranscriptChunk>
|
|
136
|
+
if (typeof chunk.text !== 'string' || typeof chunk.speaker !== 'string') return null
|
|
137
|
+
if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) return null
|
|
138
|
+
if (typeof chunk.similarity !== 'number' || !Number.isFinite(chunk.similarity)) return null
|
|
139
|
+
entries.push({ chunkIndex: candidate.chunkIndex as number, chunk: chunk as TranscriptChunk })
|
|
140
|
+
}
|
|
141
|
+
return entries.length > 0 ? entries : null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
|
|
145
|
+
const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
|
|
146
|
+
if (activeFinalizationJobs.has(key)) return
|
|
147
|
+
activeFinalizationJobs.add(key)
|
|
148
|
+
|
|
149
|
+
// Acquire before the foreground save lease can leave scope: there is no
|
|
150
|
+
// zero-count proof gap between pending-audio handoff and queued finalizer.
|
|
151
|
+
const lease = acquireMaintenanceWork('meeting_batch_finalization', {
|
|
152
|
+
allowDuringDrain: true,
|
|
153
|
+
phase: 'queued',
|
|
154
|
+
})
|
|
155
|
+
const task = Promise.resolve().then(async () => {
|
|
156
|
+
lease.setPhase('active')
|
|
157
|
+
let current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
158
|
+
if (canonicalFinalizationIsComplete(current)) {
|
|
159
|
+
runtime.finalizationJobs.remove(current.sessionId)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (current.phase === 'capture_pending') {
|
|
163
|
+
const source = runtime.sessions ?? defaultSessionSource
|
|
164
|
+
let audioDir = current.audioDir ?? runtime.finalizationJobs.findPendingAudioDir(current.sessionId)
|
|
165
|
+
if (!audioDir && source.hasAudio(current.sessionId)) {
|
|
166
|
+
try { await source.drainAudioWrites(current.sessionId) } catch { /* retain surviving evidence */ }
|
|
167
|
+
audioDir = source.moveAudioToPending(current.sessionId)
|
|
168
|
+
source.delete(current.sessionId, { preserveAudio: !audioDir })
|
|
169
|
+
}
|
|
170
|
+
const rawEntries = readFinalizationChunkEntries(current)
|
|
171
|
+
current = runtime.finalizationJobs.save({
|
|
172
|
+
sessionId: current.sessionId,
|
|
173
|
+
meetingPath: current.meetingPath,
|
|
174
|
+
sidecarPath: current.sidecarPath,
|
|
175
|
+
audioDir,
|
|
176
|
+
streamingWordCount: current.streamingWordCount,
|
|
177
|
+
phase: audioDir && rawEntries?.length ? 'batch_pending' : 'ops_pending',
|
|
178
|
+
claimPending: current.claimPending,
|
|
179
|
+
})
|
|
180
|
+
markCanonicalFinalizationState(current.sidecarPath, current.phase, current.claimPending)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (current.claimPending) {
|
|
184
|
+
let claimPending: boolean = current.claimPending
|
|
185
|
+
if (earlyMeetingSyncEnabled()) {
|
|
186
|
+
try {
|
|
187
|
+
await claimMeetingInOperations(current.meetingPath)
|
|
188
|
+
claimPending = false
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Identity acceleration must never strand canonical HQ/final sync.
|
|
191
|
+
// Keep the durable bit for replay while the final handoff proceeds.
|
|
192
|
+
console.warn(
|
|
193
|
+
`[meeting/save] Early claim deferred for ${current.sessionId}: `
|
|
194
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Runtime rollback: disabling the canary must release old claim work.
|
|
199
|
+
claimPending = false
|
|
200
|
+
}
|
|
201
|
+
current = runtime.finalizationJobs.save({
|
|
202
|
+
sessionId: current.sessionId,
|
|
203
|
+
meetingPath: current.meetingPath,
|
|
204
|
+
sidecarPath: current.sidecarPath,
|
|
205
|
+
audioDir: current.audioDir,
|
|
206
|
+
streamingWordCount: current.streamingWordCount,
|
|
207
|
+
phase: current.phase,
|
|
208
|
+
claimPending,
|
|
209
|
+
})
|
|
210
|
+
markCanonicalFinalizationState(current.sidecarPath, current.phase, claimPending)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (current.phase === 'batch_pending') {
|
|
214
|
+
if (current.audioDir && existsSync(current.audioDir)) {
|
|
215
|
+
const rawEntries = readFinalizationChunkEntries(current)
|
|
216
|
+
const entries = rawEntries ? validReplayEntries(rawEntries) : null
|
|
217
|
+
if (!entries) throw new Error('Durable finalization sidecar has no valid chunkEntries')
|
|
218
|
+
await finalizeBatch({
|
|
219
|
+
audioDir: current.audioDir,
|
|
220
|
+
entries,
|
|
221
|
+
streamingWordCount: current.streamingWordCount,
|
|
222
|
+
meetingPath: current.meetingPath,
|
|
223
|
+
sidecarPath: current.sidecarPath,
|
|
224
|
+
sessionId: current.sessionId,
|
|
225
|
+
runBatch: runtime.runBatch,
|
|
226
|
+
})
|
|
227
|
+
} else {
|
|
228
|
+
console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
|
|
229
|
+
}
|
|
230
|
+
current = runtime.finalizationJobs.save({
|
|
231
|
+
sessionId: current.sessionId,
|
|
232
|
+
meetingPath: current.meetingPath,
|
|
233
|
+
sidecarPath: current.sidecarPath,
|
|
234
|
+
audioDir: current.audioDir,
|
|
235
|
+
streamingWordCount: current.streamingWordCount,
|
|
236
|
+
phase: 'ops_pending',
|
|
237
|
+
claimPending: current.claimPending,
|
|
238
|
+
})
|
|
239
|
+
markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (cosOpsPipelineConfigured()) {
|
|
243
|
+
await handoffMeetingToOperations(current.meetingPath)
|
|
244
|
+
}
|
|
245
|
+
markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
|
|
246
|
+
runtime.finalizationJobs.remove(current.sessionId)
|
|
247
|
+
finalizationRetryCounts.delete(key)
|
|
248
|
+
}).catch(error => {
|
|
249
|
+
const current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
250
|
+
try {
|
|
251
|
+
runtime.finalizationJobs.save({
|
|
252
|
+
sessionId: current.sessionId,
|
|
253
|
+
meetingPath: current.meetingPath,
|
|
254
|
+
sidecarPath: current.sidecarPath,
|
|
255
|
+
audioDir: current.audioDir,
|
|
256
|
+
streamingWordCount: current.streamingWordCount,
|
|
257
|
+
phase: current.phase,
|
|
258
|
+
claimPending: current.claimPending,
|
|
259
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
260
|
+
})
|
|
261
|
+
} catch { /* keep the original durable job if error annotation fails */ }
|
|
262
|
+
console.error(
|
|
263
|
+
`[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
|
|
264
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
265
|
+
)
|
|
266
|
+
const retryCount = finalizationRetryCounts.get(key) ?? 0
|
|
267
|
+
if (retryCount < 2) {
|
|
268
|
+
finalizationRetryCounts.set(key, retryCount + 1)
|
|
269
|
+
const retryRetained = () => {
|
|
270
|
+
if (!maintenanceAdmissionsOpen()) {
|
|
271
|
+
const waitForAdmissions = setTimeout(retryRetained, 30_000)
|
|
272
|
+
waitForAdmissions.unref()
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const retained = runtime.finalizationJobs.get(job.sessionId)
|
|
276
|
+
if (retained) scheduleFinalizationJob(retained, runtime)
|
|
277
|
+
}
|
|
278
|
+
const retry = setTimeout(retryRetained, 60_000 * (retryCount + 1))
|
|
279
|
+
retry.unref()
|
|
280
|
+
}
|
|
281
|
+
}).finally(() => {
|
|
282
|
+
activeFinalizationJobs.delete(key)
|
|
283
|
+
lease.release()
|
|
284
|
+
})
|
|
285
|
+
runtime.scheduleBackground(task)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Boot hook: replay post-save HQ/operations work whose response already
|
|
289
|
+
* succeeded before a prior process exited. Safe to call more than once. */
|
|
290
|
+
export function resumeMeetingFinalizationJobs(): void {
|
|
291
|
+
const finalizationJobs = new MeetingFinalizationJobStore()
|
|
292
|
+
finalizationJobs.reconcileCanonicalSidecars()
|
|
293
|
+
for (const job of finalizationJobs.list()) {
|
|
294
|
+
scheduleFinalizationJob(job, {
|
|
295
|
+
finalizationJobs,
|
|
296
|
+
runBatch: runMeetingBatchPipeline,
|
|
297
|
+
scheduleBackground: task => { void task },
|
|
298
|
+
})
|
|
299
|
+
}
|
|
98
300
|
}
|
|
99
301
|
|
|
100
302
|
const defaultSessionSource: MeetingSessionSource = {
|
|
@@ -167,6 +369,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
167
369
|
const runBatch = deps.runBatch ?? runMeetingBatchPipeline
|
|
168
370
|
const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
|
|
169
371
|
const emit = deps.emit ?? emitDisplay
|
|
372
|
+
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
170
373
|
const router = Router()
|
|
171
374
|
const savingSessions = new Set<string>()
|
|
172
375
|
|
|
@@ -243,6 +446,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
243
446
|
// the sidecar by session ID so client retry/restart is idempotent.
|
|
244
447
|
const alreadySaved = store.findBySessionId(sessionId)
|
|
245
448
|
if (alreadySaved) {
|
|
449
|
+
finalizationJobs.reconcileCanonicalSidecars()
|
|
450
|
+
const pendingJob = finalizationJobs.get(sessionId)
|
|
451
|
+
if (pendingJob) scheduleFinalizationJob(pendingJob, {
|
|
452
|
+
finalizationJobs,
|
|
453
|
+
runBatch,
|
|
454
|
+
scheduleBackground,
|
|
455
|
+
sessions,
|
|
456
|
+
})
|
|
246
457
|
res.set('Cache-Control', 'private, no-store')
|
|
247
458
|
res.json(publicSaveResponse(alreadySaved, true))
|
|
248
459
|
return
|
|
@@ -279,6 +490,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
279
490
|
? durationFromTimeline
|
|
280
491
|
: Math.max(0, Date.now() - startTime)
|
|
281
492
|
const integrity = sessions.getIntegrity(sessionId)
|
|
493
|
+
const needsOperations = cosOpsPipelineConfigured()
|
|
494
|
+
const finalizationRequired = sessions.hasAudio(sessionId) || needsOperations
|
|
495
|
+
const claimPending = needsOperations && earlyMeetingSyncEnabled()
|
|
282
496
|
|
|
283
497
|
// Initial canonical text + structured metadata are published before any
|
|
284
498
|
// live state is removed or background work is scheduled.
|
|
@@ -293,8 +507,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
293
507
|
chunkEntries,
|
|
294
508
|
providerCandidates: sessions.getProviderCandidates(sessionId),
|
|
295
509
|
transferIntegrity: integrity,
|
|
510
|
+
finalizationRequired,
|
|
511
|
+
claimPending,
|
|
296
512
|
})
|
|
297
513
|
|
|
514
|
+
// Two-phase replay intent: the canonical sidecar above is the recovery
|
|
515
|
+
// source of truth, and this indexed job is its fast execution ledger.
|
|
516
|
+
let finalizationJob: MeetingFinalizationJob | null = null
|
|
517
|
+
if (finalizationRequired) {
|
|
518
|
+
finalizationJob = finalizationJobs.save({
|
|
519
|
+
sessionId,
|
|
520
|
+
meetingPath: saved.filepath,
|
|
521
|
+
sidecarPath: saved.sidecarPath,
|
|
522
|
+
audioDir: null,
|
|
523
|
+
streamingWordCount: countWords(transcript),
|
|
524
|
+
phase: 'capture_pending',
|
|
525
|
+
claimPending,
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Progressive HQ is a disposable cache, not a meeting owner. Abort any
|
|
530
|
+
// in-flight prefill before the session-audio directory is atomically
|
|
531
|
+
// renamed so no child can publish into a stale path after Stop.
|
|
532
|
+
await stopProgressiveHqSession(sessionId)
|
|
533
|
+
|
|
298
534
|
// Wait for every raw-WAV write before rename. If any write failed, retain
|
|
299
535
|
// surviving audio for recovery but do not run an incomplete batch.
|
|
300
536
|
let audioWritesReady = true
|
|
@@ -332,59 +568,37 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
332
568
|
console.warn('[meeting/save] Display notification failed after durable save:', error)
|
|
333
569
|
}
|
|
334
570
|
|
|
571
|
+
const canBatch = audioWritesReady && pendingAudioDir && chunkEntries.length > 0
|
|
572
|
+
if (canBatch || needsOperations) {
|
|
573
|
+
// The replay record is durable before the response. A lost response,
|
|
574
|
+
// process crash, or macOS update can therefore resume the exact same
|
|
575
|
+
// meeting without minting a second scribe.
|
|
576
|
+
finalizationJob = finalizationJobs.save({
|
|
577
|
+
sessionId,
|
|
578
|
+
meetingPath: saved.filepath,
|
|
579
|
+
sidecarPath: saved.sidecarPath,
|
|
580
|
+
audioDir: canBatch ? pendingAudioDir : null,
|
|
581
|
+
streamingWordCount: countWords(transcript),
|
|
582
|
+
phase: canBatch ? 'batch_pending' : 'ops_pending',
|
|
583
|
+
claimPending,
|
|
584
|
+
})
|
|
585
|
+
markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
|
|
586
|
+
} else if (finalizationJob) {
|
|
587
|
+
markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
|
|
588
|
+
finalizationJobs.remove(sessionId)
|
|
589
|
+
finalizationJob = null
|
|
590
|
+
}
|
|
591
|
+
|
|
335
592
|
res.set('Cache-Control', 'private, no-store')
|
|
336
593
|
res.json(publicSaveResponse(saved))
|
|
337
594
|
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
phase: 'queued',
|
|
595
|
+
if (finalizationJob) {
|
|
596
|
+
scheduleFinalizationJob(finalizationJob, {
|
|
597
|
+
finalizationJobs,
|
|
598
|
+
runBatch,
|
|
599
|
+
scheduleBackground,
|
|
600
|
+
sessions,
|
|
345
601
|
})
|
|
346
|
-
const task = Promise.resolve().then(async () => {
|
|
347
|
-
batchLease.setPhase('active')
|
|
348
|
-
try {
|
|
349
|
-
await finalizeBatch({
|
|
350
|
-
audioDir: pendingAudioDir,
|
|
351
|
-
entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
|
|
352
|
-
streamingWordCount: countWords(transcript),
|
|
353
|
-
meetingPath: saved.filepath,
|
|
354
|
-
sidecarPath: saved.sidecarPath,
|
|
355
|
-
runBatch,
|
|
356
|
-
})
|
|
357
|
-
} catch (error) {
|
|
358
|
-
// Raw audio deliberately remains for bounded cleanup / retry.
|
|
359
|
-
console.error(
|
|
360
|
-
`[meeting/save] Batch finalization failed for ${sessionId}: `
|
|
361
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
-
)
|
|
363
|
-
}
|
|
364
|
-
// Always hand off the durable local scribe (streaming or batch) into
|
|
365
|
-
// operations/ when COS pipeline is configured — this is what was
|
|
366
|
-
// missing on managed public server and left today's G2 files unsynced.
|
|
367
|
-
if (cosOpsPipelineConfigured()) {
|
|
368
|
-
await handoffMeetingToOperations(saved.filepath)
|
|
369
|
-
}
|
|
370
|
-
}).catch(error => {
|
|
371
|
-
console.error(
|
|
372
|
-
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
373
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
374
|
-
)
|
|
375
|
-
}).finally(() => batchLease.release())
|
|
376
|
-
scheduleBackground(task)
|
|
377
|
-
} else if (cosOpsPipelineConfigured()) {
|
|
378
|
-
// No HQ batch (no audio / incomplete writes) — still hand off the
|
|
379
|
-
// streaming scribe into operations when COS pipeline is configured.
|
|
380
|
-
scheduleBackground(
|
|
381
|
-
handoffMeetingToOperations(saved.filepath).catch(error => {
|
|
382
|
-
console.error(
|
|
383
|
-
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
384
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
385
|
-
)
|
|
386
|
-
}),
|
|
387
|
-
)
|
|
388
602
|
}
|
|
389
603
|
} catch (error) {
|
|
390
604
|
if (error instanceof MeetingStoreError) {
|
|
@@ -654,12 +868,14 @@ async function finalizeBatch(options: {
|
|
|
654
868
|
streamingWordCount: number
|
|
655
869
|
meetingPath: string
|
|
656
870
|
sidecarPath: string
|
|
871
|
+
sessionId?: string
|
|
657
872
|
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
658
873
|
}): Promise<void> {
|
|
659
874
|
const result = await options.runBatch(
|
|
660
875
|
options.audioDir,
|
|
661
876
|
options.entries,
|
|
662
877
|
options.streamingWordCount,
|
|
878
|
+
options.sessionId,
|
|
663
879
|
)
|
|
664
880
|
let transcriptApplied = false
|
|
665
881
|
let metadataPersisted = false
|
|
@@ -59,6 +59,10 @@ import {
|
|
|
59
59
|
} from '../lib/maintenance-lifecycle.js'
|
|
60
60
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
61
61
|
import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
|
|
62
|
+
import {
|
|
63
|
+
scheduleProgressiveHqCheckpoint,
|
|
64
|
+
stopProgressiveHqSession,
|
|
65
|
+
} from '../lib/meeting-batch-transcribe.js'
|
|
62
66
|
|
|
63
67
|
/** Preempt hook 2 of 2 (1C): the recording_chunk backstop. Live audio is
|
|
64
68
|
* arriving, so any Metal batch must yield the GPU now. This covers recovery,
|
|
@@ -920,13 +924,68 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
|
|
|
920
924
|
const session = sessions.get(sessionId)
|
|
921
925
|
if (!session) return null
|
|
922
926
|
const entries: IndexedTranscriptChunk[] = []
|
|
923
|
-
|
|
927
|
+
const maxIndex = Math.max(
|
|
928
|
+
session.chunks.length - 1,
|
|
929
|
+
...Object.keys(session.emptyCompletions ?? {}).map(Number).filter(Number.isInteger),
|
|
930
|
+
)
|
|
931
|
+
for (let chunkIndex = 0; chunkIndex <= maxIndex; chunkIndex++) {
|
|
924
932
|
const chunk = session.chunks[chunkIndex]
|
|
925
|
-
if (chunk?.text)
|
|
933
|
+
if (chunk?.text) {
|
|
934
|
+
entries.push({ chunkIndex, chunk })
|
|
935
|
+
continue
|
|
936
|
+
}
|
|
937
|
+
const empty = session.emptyCompletions?.[String(chunkIndex)]
|
|
938
|
+
if (empty) {
|
|
939
|
+
entries.push({
|
|
940
|
+
chunkIndex,
|
|
941
|
+
chunk: {
|
|
942
|
+
text: '',
|
|
943
|
+
speaker: empty.speaker || 'Ext',
|
|
944
|
+
elapsed: empty.elapsed,
|
|
945
|
+
similarity: 0,
|
|
946
|
+
backend: empty.backend,
|
|
947
|
+
asrProvider: empty.asrProvider === 'iphone-whisperkit-beta'
|
|
948
|
+
? 'iphone-whisperkit-beta'
|
|
949
|
+
: empty.asrProvider === 'server-whisper'
|
|
950
|
+
? 'server-whisper'
|
|
951
|
+
: undefined,
|
|
952
|
+
fallbackReason: empty.fallbackReason,
|
|
953
|
+
},
|
|
954
|
+
})
|
|
955
|
+
}
|
|
926
956
|
}
|
|
927
957
|
return entries
|
|
928
958
|
}
|
|
929
959
|
|
|
960
|
+
/** Private local path for guarded progressive HQ cache work. */
|
|
961
|
+
export function getSessionAudioDirectory(sessionId: string): string | null {
|
|
962
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
963
|
+
const path = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
964
|
+
return existsSync(path) ? path : null
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Snapshot the durable ASR-completion ledger for progressive HQ admission.
|
|
968
|
+
* Includes canonical and intentionally empty/silent chunks. */
|
|
969
|
+
export function getSessionAsrCompletedIndices(sessionId: string): number[] {
|
|
970
|
+
return [...(sessions.get(sessionId)?.asrCompletedIndices ?? [])]
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function scheduleSessionProgressiveHq(sessionId: string): void {
|
|
974
|
+
// Preserve the default-off contract at the route boundary. In addition to
|
|
975
|
+
// avoiding any cache work, this keeps legacy/local-first transcription
|
|
976
|
+
// independent of the progressive HQ capability surface when the canary is
|
|
977
|
+
// disabled (including partial test and recovery environments).
|
|
978
|
+
if (process.env.COS_MEETING_PROGRESSIVE_HQ !== '1') return
|
|
979
|
+
const audioDir = getSessionAudioDirectory(sessionId)
|
|
980
|
+
if (!audioDir) return
|
|
981
|
+
scheduleProgressiveHqCheckpoint(
|
|
982
|
+
sessionId,
|
|
983
|
+
audioDir,
|
|
984
|
+
getSessionChunkEntries(sessionId) ?? [],
|
|
985
|
+
getSessionAsrCompletedIndices(sessionId),
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
|
|
930
989
|
/** Get session start time */
|
|
931
990
|
export function getSessionStartTime(sessionId: string): number | null {
|
|
932
991
|
return sessions.get(sessionId)?.startTime ?? null
|
|
@@ -1044,6 +1103,9 @@ function closeTranscriptSession(
|
|
|
1044
1103
|
reason: ClosedTranscriptSession['reason'],
|
|
1045
1104
|
options: { preserveAudio?: boolean } = {},
|
|
1046
1105
|
): void {
|
|
1106
|
+
// Progressive HQ is disposable cache state. Every terminal session path —
|
|
1107
|
+
// save, expiry, quarantine, or abandonment — must release the single owner.
|
|
1108
|
+
void stopProgressiveHqSession(sessionId)
|
|
1047
1109
|
const session = sessions.get(sessionId)
|
|
1048
1110
|
const now = Date.now()
|
|
1049
1111
|
const receivedIndices = [...(session?.receivedIndices ?? [])]
|
|
@@ -1610,6 +1672,7 @@ async function processStreamChunk(opts: {
|
|
|
1610
1672
|
session.emptyCompletions[String(chunkIndex)] = emptyCompletion
|
|
1611
1673
|
recordAsrCompleted(session, chunkIndex)
|
|
1612
1674
|
persistSessionRequired(sessionId)
|
|
1675
|
+
scheduleSessionProgressiveHq(sessionId)
|
|
1613
1676
|
return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
|
|
1614
1677
|
}
|
|
1615
1678
|
|
|
@@ -1654,6 +1717,8 @@ async function processStreamChunk(opts: {
|
|
|
1654
1717
|
persistSessionRequired(sessionId)
|
|
1655
1718
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1656
1719
|
|
|
1720
|
+
scheduleSessionProgressiveHq(sessionId)
|
|
1721
|
+
|
|
1657
1722
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
1658
1723
|
// Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
|
|
1659
1724
|
// block on a cue, and no LLM runs on this path. .catch() is required — a
|