@gotcos/glasses-server 6.92.0 → 6.94.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/.env.example +30 -1
- package/CHANGELOG.md +72 -29
- package/README.md +70 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +7 -0
- package/server/lib/g2-ops-handoff.ts +204 -2
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/__fixtures__/g2-filing-golden-6.93.0.json +62 -0
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +231 -20
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
package/server/routes/meeting.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
2
|
+
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
|
|
1
3
|
import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
|
|
2
4
|
import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
3
5
|
// POST /api/meeting/save — finalize an existing transcribe-stream session into
|
|
@@ -42,6 +44,8 @@ import {
|
|
|
42
44
|
} from '../lib/meeting-audio-archive.js'
|
|
43
45
|
import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
44
46
|
import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
|
|
47
|
+
import { commitMacVoiceAssignment, planMacVoiceAssignment, type MacAssignGate, type MacAssignPlan, type MacAssignSkip } from '../lib/mac-voice-assignment.js'
|
|
48
|
+
import { isMacCaptureSidecar, isVoiceLabel } from '../lib/mac-voice-labels.js'
|
|
45
49
|
|
|
46
50
|
function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
|
|
47
51
|
try {
|
|
@@ -183,6 +187,7 @@ import {
|
|
|
183
187
|
import {
|
|
184
188
|
claimMeetingInOperations,
|
|
185
189
|
earlyMeetingSyncEnabled,
|
|
190
|
+
fileMacRecordingInLibrary,
|
|
186
191
|
handoffMeetingToOperations,
|
|
187
192
|
} from '../lib/g2-ops-handoff.js'
|
|
188
193
|
import {
|
|
@@ -211,6 +216,33 @@ function cosOpsPipelineConfigured(): boolean {
|
|
|
211
216
|
return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
|
|
212
217
|
}
|
|
213
218
|
|
|
219
|
+
/**
|
|
220
|
+
* 6.94.0: file a finished Mac recording into the COS meeting library.
|
|
221
|
+
*
|
|
222
|
+
* Runs AFTER the standalone enrichment, so the local recording is exactly what 6.93.0 left
|
|
223
|
+
* whether or not filing works. A Mac recording is never handed to the G2 import: it takes
|
|
224
|
+
* sync_meetings.py --mac-import-file, and only when that script reports the capability.
|
|
225
|
+
* A failure is logged and never fails the capture's own finalization, because a retry would
|
|
226
|
+
* re-run the standalone summary; mac_library_backfill.py files it later, idempotently.
|
|
227
|
+
*/
|
|
228
|
+
async function fileFinishedMacRecording(sessionId: string, meetingPath: string, label: string): Promise<void> {
|
|
229
|
+
// Callers pass Mac captures only (finalization and orphan recovery both check MAC_CAPTURE_ID).
|
|
230
|
+
if (!cosOpsPipelineConfigured()) return
|
|
231
|
+
try {
|
|
232
|
+
const filing = await fileMacRecordingInLibrary(meetingPath)
|
|
233
|
+
if (filing.outcome === 'filed') {
|
|
234
|
+
console.log(`[${label}] Mac recording ${sessionId} filed into the library (${filing.status}): ${filing.path}`)
|
|
235
|
+
} else {
|
|
236
|
+
console.log(`[${label}] Mac recording ${sessionId} stays standalone: ${filing.reason}`)
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.warn(
|
|
240
|
+
`[${label}] Mac recording ${sessionId} was not filed into the library: `
|
|
241
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
214
246
|
/**
|
|
215
247
|
* Why a correction left the meeting's markdown alone when it could not read it.
|
|
216
248
|
*
|
|
@@ -264,6 +296,8 @@ function defaultSessionLedger(sessionId: string): SessionLedger {
|
|
|
264
296
|
|
|
265
297
|
export interface MeetingRouteDependencies {
|
|
266
298
|
store?: MeetingStore
|
|
299
|
+
/** 6.93.0: is any meeting capture (glasses, Mac or iPhone) live right now? Assigning a Mac voice to a profile is refused while one is. */
|
|
300
|
+
captureActive?: () => boolean
|
|
267
301
|
sessions?: MeetingSessionSource
|
|
268
302
|
runBatch?: (
|
|
269
303
|
audioDir: string,
|
|
@@ -439,7 +473,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
439
473
|
// summary describes, the cleaned text. Off unless Miles turns it on.
|
|
440
474
|
await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)
|
|
441
475
|
|
|
442
|
-
if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
|
|
476
|
+
if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId) && !PHONE_CAPTURE_ID.test(current.sessionId)) {
|
|
443
477
|
await handoffMeetingToOperations(current.meetingPath)
|
|
444
478
|
} else {
|
|
445
479
|
// Standalone: no sync_meetings.py to produce summary/topics/decisions.
|
|
@@ -452,11 +486,18 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
452
486
|
// The capture is finished and its files are durable, so the merge engine may score it.
|
|
453
487
|
// Fire-and-forget by contract: the runner queues, defers under a drain or a live
|
|
454
488
|
// capture, and never throws back at the finalization path.
|
|
455
|
-
if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
489
|
+
if (!MAC_CAPTURE_ID.test(current.sessionId) && !PHONE_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
456
490
|
runtime.finalizationJobs.remove(current.sessionId)
|
|
457
491
|
// 6.78.0: finished, so its crash-loop count (lib/boot-resume.ts) is done.
|
|
458
492
|
bootResumeGate().forget('meeting_finalization', current.sessionId)
|
|
459
493
|
finalizationRetryCounts.delete(key)
|
|
494
|
+
// Filed AFTER the capture's own finalization is complete and its job removed: the
|
|
495
|
+
// filing holds no maintenance lease (a restart drain never waits on it) and a restart
|
|
496
|
+
// mid-filing never re-runs the standalone summary. An interrupted filing leaves the
|
|
497
|
+
// staged copy unfinished, and sync_meetings' broad sync retries it.
|
|
498
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) {
|
|
499
|
+
runtime.scheduleBackground(fileFinishedMacRecording(current.sessionId, current.meetingPath, 'meeting/save'))
|
|
500
|
+
}
|
|
460
501
|
}).catch(error => {
|
|
461
502
|
const current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
462
503
|
try {
|
|
@@ -559,7 +600,7 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
|
|
|
559
600
|
: 100
|
|
560
601
|
return {
|
|
561
602
|
receiptVersion: 1,
|
|
562
|
-
...(saved.sourceType
|
|
603
|
+
...(saved.sourceType ? { sourceType: saved.sourceType, sessionId: saved.captureId, captureId: saved.captureId, ...(saved.sourceType === PHONE_SOURCE_TYPE ? { speakerPolicy: 'anonymous' } : {}) } : {}),
|
|
563
604
|
serverInstanceId: getServerInstanceId(),
|
|
564
605
|
saved: true,
|
|
565
606
|
// Keep the build199 string field without leaking an absolute host path.
|
|
@@ -646,6 +687,31 @@ function suppliedServerPin(
|
|
|
646
687
|
return header || secondary || null
|
|
647
688
|
}
|
|
648
689
|
|
|
690
|
+
const MAC_ASSIGN_SKIP_TEXT: Record<string, string> = {
|
|
691
|
+
not_mac_capture: 'this is not a Mac recording',
|
|
692
|
+
not_a_voice_label: 'only an unnamed Voice N can be assigned',
|
|
693
|
+
unknown_profile: 'that name is not an enrolled voice profile',
|
|
694
|
+
disabled: 'voice samples are switched off on this server (COS_CHUNK_EMBEDDINGS)',
|
|
695
|
+
store_unavailable: 'the speaker model is not loaded on this Mac, or the voice profile store could not be written',
|
|
696
|
+
no_index_mapping: 'this recording has no chunk index map to find its audio',
|
|
697
|
+
audio_not_retained: 'the recording audio is no longer retained (it is kept for 7 days)',
|
|
698
|
+
no_usable_audio: 'no usable audio from this voice, or the audio does not sound like one person',
|
|
699
|
+
owner_profile: 'that is the wearer profile, and a Mac recording has no wearer microphone',
|
|
700
|
+
no_stored_voice: 'that profile has no stored voice to compare against',
|
|
701
|
+
profile_full: 'that profile is full and holds no Mac-assigned sample to replace',
|
|
702
|
+
deadline: 'reading the audio took longer than the time limit; ask again to continue where it stopped',
|
|
703
|
+
}
|
|
704
|
+
const macAssignSkipText = (skipped: MacAssignSkip): string => (skipped && MAC_ASSIGN_SKIP_TEXT[skipped]) || String(skipped)
|
|
705
|
+
|
|
706
|
+
const MAC_ASSIGN_GATE_TEXT: Record<MacAssignGate, string> = {
|
|
707
|
+
below_floor: 'this voice sounds unlike that profile (under the 0.55 floor a voice merge also refuses at)',
|
|
708
|
+
mixed_voice: 'this Voice N holds more than one voice, so the dominant one may not be the person you picked',
|
|
709
|
+
too_little_audio: 'there is too little clean audio from this voice to trust one short sample',
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Meetings with an assignment plan running now. One at a time per meeting, so two requests cannot interleave their reads and writes. */
|
|
713
|
+
const macAssignInFlight = new Set<string>()
|
|
714
|
+
|
|
649
715
|
export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
|
|
650
716
|
const store = deps.store ?? getMeetingStore()
|
|
651
717
|
const sessions = deps.sessions ?? defaultSessionSource
|
|
@@ -656,6 +722,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
656
722
|
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
657
723
|
const transcriptCleanup = deps.transcriptCleanup
|
|
658
724
|
const router = Router()
|
|
725
|
+
const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
|
|
659
726
|
const savingSessions = new Set<string>()
|
|
660
727
|
const stopRequests = deps.stopRequests ?? createStopRequestStore()
|
|
661
728
|
setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
|
|
@@ -740,6 +807,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
740
807
|
let recoveryPendingLogged = false
|
|
741
808
|
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
742
809
|
const sessionId = String(req.params.sessionId ?? '')
|
|
810
|
+
let phoneProvenanceState: 'admitted' | 'not_admitted' | undefined
|
|
811
|
+
if (sessionId.startsWith('phone_')) {
|
|
812
|
+
try {
|
|
813
|
+
if (readPhoneRecording(sessionId)) { requirePhoneRecording(sessionId); phoneProvenanceState = 'admitted' }
|
|
814
|
+
else if (existsSync(dataPath('session-audio', sessionId)) || existsSync(dataPath('meeting-audio', sessionId)) || existsSync(dataPath('pending-batch', sessionId)) || existsSync(dataPath('active-sessions', sessionId + '.json')) || findQuarantineDir(sessionId)) requirePhoneRecording(sessionId)
|
|
815
|
+
else { assertPhoneProvenance(sessionId, req.query); phoneProvenanceState = 'not_admitted' }
|
|
816
|
+
} catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 409).json({ error: e.message, reason: e.reason }); return }
|
|
817
|
+
}
|
|
743
818
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
744
819
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
745
820
|
return
|
|
@@ -755,6 +830,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
755
830
|
return
|
|
756
831
|
}
|
|
757
832
|
const saved = store.findBySessionId(sessionId)
|
|
833
|
+
if (phoneProvenanceState === 'admitted' && readPhoneRecording(sessionId)?.closedAt && !saved) { res.status(409).json({ error: 'Saved iPhone metadata is incomplete; audio is retained', reason: 'phone_saved_metadata_unreadable' }); return }
|
|
758
834
|
// 6.71.14: before durable recovery has run (a boot behind a closed or stuck
|
|
759
835
|
// maintenance gate), a persisted meeting is not in memory YET, and answering
|
|
760
836
|
// `missing` would tell the phone the Mac lost it. Every phone status caller
|
|
@@ -786,6 +862,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
786
862
|
res.set('Cache-Control', 'private, no-store')
|
|
787
863
|
res.json({
|
|
788
864
|
sessionId,
|
|
865
|
+
...(phoneProvenanceState ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', provenanceState: phoneProvenanceState } : {}),
|
|
789
866
|
state,
|
|
790
867
|
serverInstanceId,
|
|
791
868
|
receivedRanges: live.receivedRanges,
|
|
@@ -902,6 +979,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
902
979
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
903
980
|
return null
|
|
904
981
|
}
|
|
982
|
+
if (sessionId.startsWith('phone_')) {
|
|
983
|
+
res.status(400).json({ error: 'Stop this recording on iPhone', reason: 'phone_capture_stop_on_iphone' })
|
|
984
|
+
return null
|
|
985
|
+
}
|
|
905
986
|
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
906
987
|
// A Mac recording stops from Control itself; it has no glasses to ask.
|
|
907
988
|
res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
|
|
@@ -995,6 +1076,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
995
1076
|
// off screen still tells the Mac. It never closes the session and never refuses a chunk:
|
|
996
1077
|
// the server saves on its own only once it holds every chunk 0..finalChunkIndex.
|
|
997
1078
|
router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
|
|
1079
|
+
try { assertPhoneProvenance(String(req.params.sessionId ?? ''), req.body) } catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 400).json({ error: e.message, reason: e.reason }); return }
|
|
998
1080
|
const sessionId = String(req.params.sessionId ?? '')
|
|
999
1081
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1000
1082
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
@@ -1109,6 +1191,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1109
1191
|
try {
|
|
1110
1192
|
// A response can be lost after both durable files were committed. Find
|
|
1111
1193
|
// the sidecar by session ID so client retry/restart is idempotent.
|
|
1194
|
+
if (sessionId.startsWith('phone_')) {
|
|
1195
|
+
try { requirePhoneRecording(sessionId) } catch (error) { const failure = error as Error & { status?: number; reason?: string }; throw new MeetingStoreError(failure.message, failure.status ?? 409, failure.reason ?? 'phone_capture_provenance_missing') }
|
|
1196
|
+
}
|
|
1112
1197
|
const alreadySaved = store.findBySessionId(sessionId)
|
|
1113
1198
|
if (alreadySaved) {
|
|
1114
1199
|
finalizationJobs.reconcileCanonicalSidecars()
|
|
@@ -1126,6 +1211,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1126
1211
|
respond(200, publicSaveResponse(alreadySaved, true), true)
|
|
1127
1212
|
return alreadySaved
|
|
1128
1213
|
}
|
|
1214
|
+
if (PHONE_CAPTURE_ID.test(sessionId) && requirePhoneRecording(sessionId).closedAt) throw new MeetingStoreError('Saved iPhone source metadata is incomplete; audio is retained', 409, 'phone_saved_metadata_unreadable')
|
|
1129
1215
|
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
1130
1216
|
requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
|
|
1131
1217
|
}
|
|
@@ -1176,17 +1262,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1176
1262
|
?? chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk }))
|
|
1177
1263
|
const startTime = sessions.getStartTime(sessionId) ?? Date.now()
|
|
1178
1264
|
const macRecording = readMacRecording(sessionId)
|
|
1265
|
+
const phoneRecording = readPhoneRecording(sessionId)
|
|
1179
1266
|
const durationFromTimeline = macRecording
|
|
1180
1267
|
? macChunkReceipt(sessionId, Number(body?.expectedChunkCount) - 1)?.endElapsed ?? 0
|
|
1181
1268
|
: chunks.reduce(
|
|
1182
|
-
(maximum, chunk) => Math.max(maximum, chunk?.elapsed ?? 0),
|
|
1269
|
+
(maximum, chunk) => Math.max(maximum, (chunk?.elapsed ?? 0) + (PHONE_CAPTURE_ID.test(sessionId) ? chunk?.audioDurationMs ?? 0 : 0)),
|
|
1183
1270
|
0,
|
|
1184
1271
|
)
|
|
1185
1272
|
const durationMs = durationFromTimeline > 0
|
|
1186
1273
|
? durationFromTimeline
|
|
1187
1274
|
: Math.max(0, Date.now() - startTime)
|
|
1188
1275
|
const integrity = withPhoneFinalIndex(sessions.getIntegrity(sessionId), phoneFinal)
|
|
1189
|
-
const needsOperations = cosOpsPipelineConfigured() && !macRecording
|
|
1276
|
+
const needsOperations = cosOpsPipelineConfigured() && !macRecording && !phoneRecording
|
|
1190
1277
|
// Standalone saves need a finalization pass too, for summary enrichment.
|
|
1191
1278
|
// Before 6.37 a standalone save with no audio nulled the job below and
|
|
1192
1279
|
// never reached the ops_pending slot, so enrichment could never run.
|
|
@@ -1301,7 +1388,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1301
1388
|
markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
|
|
1302
1389
|
} else if (finalizationJob) {
|
|
1303
1390
|
markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
|
|
1304
|
-
if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
1391
|
+
if (!readMacRecording(sessionId) && !PHONE_CAPTURE_ID.test(sessionId)) triggerMeetingMergeRun('g2_finalized')
|
|
1305
1392
|
finalizationJobs.remove(sessionId)
|
|
1306
1393
|
bootResumeGate().forget('meeting_finalization', sessionId)
|
|
1307
1394
|
finalizationJob = null
|
|
@@ -1361,13 +1448,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1361
1448
|
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
1362
1449
|
return
|
|
1363
1450
|
}
|
|
1451
|
+
try { assertPhoneProvenance(sessionId, body) } catch (error) { const e = error as Error & { status?: number; reason?: string }; respond(e.status ?? 400, { error: e.message, reason: e.reason }); return }
|
|
1364
1452
|
const serverInstanceId = getServerInstanceId()
|
|
1365
1453
|
if (!serverInstanceId) {
|
|
1366
1454
|
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
1367
1455
|
return
|
|
1368
1456
|
}
|
|
1369
1457
|
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
1370
|
-
if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
|
|
1458
|
+
if ((MAC_CAPTURE_ID.test(sessionId) || PHONE_CAPTURE_ID.test(sessionId)) && !pin) {
|
|
1371
1459
|
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
1372
1460
|
return
|
|
1373
1461
|
}
|
|
@@ -1376,7 +1464,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1376
1464
|
return
|
|
1377
1465
|
}
|
|
1378
1466
|
// Only the phone pins its saves (the local-first contract); the stranded sweep does not.
|
|
1379
|
-
await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
|
|
1467
|
+
await performSave(sessionId, body, respond, pin && req.get('X-COS-Recording-Origin') !== 'stranded-sweep' ? 'phone' : 'client')
|
|
1380
1468
|
} catch (error) {
|
|
1381
1469
|
console.error('[meeting/save] Finalization failed:', error)
|
|
1382
1470
|
respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
@@ -1829,7 +1917,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1829
1917
|
* Replay a recorded correction's enrolment — the retroactive path.
|
|
1830
1918
|
*
|
|
1831
1919
|
* WHY. Enrolment fires inside `POST /relabel`, so a voice named BEFORE that shipped
|
|
1832
|
-
* has a correct transcript and no profile.
|
|
1920
|
+
* has a correct transcript and no profile. Wren Hale is the live case: named
|
|
1833
1921
|
* across two meetings (60 and 109 chunks), 182 mentions in the sidecars, absent from
|
|
1834
1922
|
* a 77-profile store. Re-running the rename cannot help — she is already a real
|
|
1835
1923
|
* name there, so the placeholder guard correctly declines.
|
|
@@ -1847,7 +1935,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1847
1935
|
* refusal when unmappable, voice coherence, the diversity cap and the
|
|
1848
1936
|
* `correction:<sessionId>` tag all apply identically. A named `from` is still
|
|
1849
1937
|
* enrolled when `to` is a real person — that is how a wrong existing match
|
|
1850
|
-
* becomes a new profile (
|
|
1938
|
+
* becomes a new profile (Cole Garner → Leo Ashby). Placeholder targets
|
|
1851
1939
|
* stay idle.
|
|
1852
1940
|
*
|
|
1853
1941
|
* FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
|
|
@@ -1924,7 +2012,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1924
2012
|
})
|
|
1925
2013
|
})
|
|
1926
2014
|
|
|
1927
|
-
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
2015
|
+
router.post('/meeting/:sessionId/relabel', async (req, res) => {
|
|
1928
2016
|
res.set('Cache-Control', 'private, no-store')
|
|
1929
2017
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1930
2018
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
@@ -2010,6 +2098,63 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2010
2098
|
return
|
|
2011
2099
|
}
|
|
2012
2100
|
|
|
2101
|
+
// 6.93.0: a Mac recording voice assigned to an EXISTING voice profile. Opt-in per
|
|
2102
|
+
// request (`assignVoiceProfile: true`); without it a Mac rename stays text only.
|
|
2103
|
+
// Planned BEFORE anything is written: the preview shows exactly what would be added,
|
|
2104
|
+
// and a confirm that would add nothing is refused with the rename unapplied.
|
|
2105
|
+
// 6.93.0: refusals first (owner, a live capture), then an async plan that yields to the
|
|
2106
|
+
// event loop, then a re-check that nothing moved while it ran.
|
|
2107
|
+
let macAssign: MacAssignPlan | null = null
|
|
2108
|
+
if (req.body?.assignVoiceProfile === true) {
|
|
2109
|
+
let parsedForAssign: Record<string, unknown> = {}
|
|
2110
|
+
try {
|
|
2111
|
+
const doc = JSON.parse(sidecarRaw) as unknown
|
|
2112
|
+
if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedForAssign = doc as Record<string, unknown>
|
|
2113
|
+
} catch { /* relabelSidecarJson already parsed it */ }
|
|
2114
|
+
if (!isMacCaptureSidecar(parsedForAssign, sessionId)) {
|
|
2115
|
+
res.status(400).json({ error: 'assignVoiceProfile applies to Mac recordings only; a glasses rename already trains the profile', reason: 'not_mac_capture' })
|
|
2116
|
+
return
|
|
2117
|
+
}
|
|
2118
|
+
if (!isVoiceLabel(from)) {
|
|
2119
|
+
res.status(400).json({ error: 'Only an unnamed Voice N can be assigned to a voice profile', reason: 'not_a_voice_label' })
|
|
2120
|
+
return
|
|
2121
|
+
}
|
|
2122
|
+
// The wearer's own profile is refused inside the plan (`owner_profile`): a Mac recording has no wearer microphone, and a wrong
|
|
2123
|
+
// sample there would poison owner detection. The route only has to pass the label and map the answer.
|
|
2124
|
+
const ownerLabel = getOwnerSpeakerLabel()
|
|
2125
|
+
// Embedding and the profile write share this server with a live recording. Never while one runs.
|
|
2126
|
+
if (captureActive()) {
|
|
2127
|
+
res.status(409).json({ error: 'A meeting is being recorded right now. Assign the voice after it ends, so the recording is never held up', reason: 'capture_active', retryable: true })
|
|
2128
|
+
return
|
|
2129
|
+
}
|
|
2130
|
+
if (macAssignInFlight.has(sessionId)) {
|
|
2131
|
+
res.status(409).json({ error: 'Another voice assignment for this meeting is still running', reason: 'assign_in_progress', retryable: true })
|
|
2132
|
+
return
|
|
2133
|
+
}
|
|
2134
|
+
macAssignInFlight.add(sessionId)
|
|
2135
|
+
try {
|
|
2136
|
+
macAssign = await planMacVoiceAssignment({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedForAssign, ownerLabel })
|
|
2137
|
+
} finally {
|
|
2138
|
+
macAssignInFlight.delete(sessionId)
|
|
2139
|
+
}
|
|
2140
|
+
// Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
|
|
2141
|
+
let sidecarAfter: string | null = null
|
|
2142
|
+
try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
|
|
2143
|
+
const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
|
|
2144
|
+
if (sidecarAfter !== sidecarRaw || revisionAfter) {
|
|
2145
|
+
res.status(409).json({ error: 'The meeting changed while the voice was being read. Reopen it and try again; nothing was changed', reason: 'meeting_changed', retryable: true })
|
|
2146
|
+
return
|
|
2147
|
+
}
|
|
2148
|
+
if (macAssign.report.skipped === 'owner_profile') {
|
|
2149
|
+
res.status(409).json({ error: 'That is the wearer profile. A Mac recording cannot add voice samples to it, so nothing was changed', reason: 'owner_profile' })
|
|
2150
|
+
return
|
|
2151
|
+
}
|
|
2152
|
+
if (macAssign.report.skipped === 'unknown_profile') {
|
|
2153
|
+
res.status(404).json({ error: `"${to}" is not an enrolled voice profile, so nothing was changed`, reason: 'unknown_profile', enrolment: macAssign.report })
|
|
2154
|
+
return
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2013
2158
|
// The markdown may only be rewritten when EVERY chunk carrying `from` is
|
|
2014
2159
|
// covered. Its turn segmentation does not match the sidecar's, so a partial
|
|
2015
2160
|
// relabel has no way to know which turns the selected chunks became.
|
|
@@ -2051,7 +2196,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2051
2196
|
if (req.body?.dryRun === true || req.body?.confirm !== true) {
|
|
2052
2197
|
res.status(req.body?.dryRun === true ? 200 : 400).json({
|
|
2053
2198
|
...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
|
|
2199
|
+
...(macAssign ? { enrolment: macAssign.report } : {}),
|
|
2054
2200
|
message: `Relabelling ${surfaces.sidecar} chunk(s) from "${from}" to "${to}" in this meeting`
|
|
2201
|
+
+ (macAssign
|
|
2202
|
+
? macAssign.report.skipped === null
|
|
2203
|
+
? `, and adding up to ${macAssign.report.enrolled} voice sample(s) to ${to}'s profile`
|
|
2204
|
+
+ (macAssign.report.gates.length > 0
|
|
2205
|
+
? `. The server will refuse that unless you confirm adding it anyway: ${macAssign.report.gates.map(g => MAC_ASSIGN_GATE_TEXT[g]).join('; ')}`
|
|
2206
|
+
: '')
|
|
2207
|
+
: `. Assigning is not possible: ${macAssignSkipText(macAssign.report.skipped)}. You can still rename the speaker in the transcript without adding any voice samples`
|
|
2208
|
+
: '')
|
|
2055
2209
|
+ (preview.proseStale
|
|
2056
2210
|
? '. The summary and decisions still name the old speaker and are NOT rewritten — '
|
|
2057
2211
|
+ `prose refers to people by first name (${preview.proseHits.join(', ')}), `
|
|
@@ -2062,15 +2216,59 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2062
2216
|
return
|
|
2063
2217
|
}
|
|
2064
2218
|
|
|
2219
|
+
// Assigning to a profile with nothing to add is refused whole: the person asked for
|
|
2220
|
+
// samples, so a rename that silently adds none is not what they confirmed.
|
|
2221
|
+
if (macAssign && macAssign.report.skipped !== null) {
|
|
2222
|
+
res.status(422).json({
|
|
2223
|
+
error: `Nothing was changed. Assigning is not possible: ${macAssignSkipText(macAssign.report.skipped)}`,
|
|
2224
|
+
reason: 'nothing_to_enrol',
|
|
2225
|
+
enrolment: macAssign.report,
|
|
2226
|
+
})
|
|
2227
|
+
return
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// 6.93.0: a wrong voice is never written into a real person's profile on a warning alone. Below the floor,
|
|
2231
|
+
// a mixed group or too little audio is a 409 unless the caller sends `force: true` (COS Control's separate
|
|
2232
|
+
// "Add anyway"). The plan was just recomputed against the profile as it is now, so a change since the preview counts.
|
|
2233
|
+
if (macAssign && macAssign.report.gates.length > 0 && req.body?.force !== true) {
|
|
2234
|
+
const gate = macAssign.report.gates[0]
|
|
2235
|
+
res.status(409).json({
|
|
2236
|
+
error: `Nothing was changed. ${MAC_ASSIGN_GATE_TEXT[gate].replace(/^./, c => c.toUpperCase())}. Add it anyway only if you are sure`,
|
|
2237
|
+
reason: gate,
|
|
2238
|
+
gates: macAssign.report.gates,
|
|
2239
|
+
enrolment: macAssign.report,
|
|
2240
|
+
})
|
|
2241
|
+
return
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// A capture that started while the plan ran must not meet the profile write.
|
|
2245
|
+
if (macAssign && captureActive()) {
|
|
2246
|
+
res.status(409).json({ error: 'A meeting started recording while the voice was being read. Assign the voice after it ends; nothing was changed', reason: 'capture_active', retryable: true })
|
|
2247
|
+
return
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// The plan awaited, so a finalization pass may have started for this meeting since the check at the top of the route.
|
|
2251
|
+
// A job record exists from save to completion, which makes that unlikely today; this keeps it from depending on it.
|
|
2252
|
+
if (macAssign) {
|
|
2253
|
+
const finalizingAfter = finalizingRefusal(sessionId)
|
|
2254
|
+
if (finalizingAfter) {
|
|
2255
|
+
res.status(finalizingAfter.status).json(finalizingAfter.body)
|
|
2256
|
+
return
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2065
2260
|
// A prior correction that never closed means this meeting's files may already
|
|
2066
2261
|
// be half-written. Refuse rather than layering a second rewrite on top.
|
|
2262
|
+
// `force` on an assignment means "add this voice anyway" (above). It must not also skip this guard.
|
|
2067
2263
|
const stalled = pendingCorrections(sessionId)
|
|
2068
|
-
if (stalled.length > 0 && req.body?.force !== true) {
|
|
2264
|
+
if (stalled.length > 0 && (req.body?.force !== true || macAssign)) {
|
|
2069
2265
|
res.status(409).json({
|
|
2070
2266
|
error: 'a previous correction on this meeting never completed',
|
|
2071
2267
|
reason: 'correction_pending',
|
|
2072
2268
|
pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
|
|
2073
|
-
message:
|
|
2269
|
+
message: macAssign
|
|
2270
|
+
? 'Its files may be partly rewritten. Re-check the meeting, or do a text-only rename with { force: true } first.'
|
|
2271
|
+
: 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
|
|
2074
2272
|
})
|
|
2075
2273
|
return
|
|
2076
2274
|
}
|
|
@@ -2125,7 +2323,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2125
2323
|
if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
|
|
2126
2324
|
} catch { /* enrolment refuses on an unusable sidecar; the rename already landed */ }
|
|
2127
2325
|
|
|
2128
|
-
const enrolment =
|
|
2326
|
+
const enrolment = macAssign
|
|
2327
|
+
? commitMacVoiceAssignment(sessionId, macAssign)
|
|
2328
|
+
: enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
|
|
2129
2329
|
res.json({
|
|
2130
2330
|
ok: true,
|
|
2131
2331
|
correctionId: id,
|
|
@@ -2153,7 +2353,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2153
2353
|
// profile, so the wrong voice is now part of what the system thinks they sound
|
|
2154
2354
|
// like and will keep matching. Removing only the label fixes the transcript and
|
|
2155
2355
|
// leaves the profile poisoned — the exact mechanism that grew the phantom
|
|
2156
|
-
// "
|
|
2356
|
+
// "Evan Hartwell" from 3 mislabelled seeds to 18 samples.
|
|
2157
2357
|
/**
|
|
2158
2358
|
* Confirm the identifier was RIGHT about a label the display floor demoted.
|
|
2159
2359
|
*
|
|
@@ -2685,6 +2885,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2685
2885
|
return
|
|
2686
2886
|
}
|
|
2687
2887
|
const body = (req.body ?? {}) as Record<string, unknown>
|
|
2888
|
+
if (sessionId.startsWith('phone_')) { try { requirePhoneRecording(sessionId) } catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 409).json({ error: e.message, reason: e.reason }); return } }
|
|
2688
2889
|
if (body.title !== undefined && typeof body.title !== 'string') {
|
|
2689
2890
|
res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
|
|
2690
2891
|
return
|
|
@@ -2719,7 +2920,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2719
2920
|
}
|
|
2720
2921
|
const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
|
|
2721
2922
|
const macCapture = readMacRecording(sessionId)
|
|
2923
|
+
const phoneCapture = readPhoneRecording(sessionId)
|
|
2722
2924
|
const entries = capture.entries.map(entry => {
|
|
2925
|
+
if (phoneCapture) return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'anonymous' as const, sourceType: PHONE_SOURCE_TYPE } }
|
|
2723
2926
|
if (!macCapture) return entry
|
|
2724
2927
|
const receipt = macChunkReceipt(sessionId, entry.chunkIndex)
|
|
2725
2928
|
return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'high-confidence' as const,
|
|
@@ -2758,6 +2961,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2758
2961
|
+ 'ran for this capture. The capture leaves the unsaved count once its scribe is durable.',
|
|
2759
2962
|
})
|
|
2760
2963
|
|
|
2964
|
+
let macFilingPath: string | null = null
|
|
2761
2965
|
const task = Promise.resolve().then(async () => {
|
|
2762
2966
|
lease.setPhase('active')
|
|
2763
2967
|
const startedAt = Date.now()
|
|
@@ -2797,6 +3001,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2797
3001
|
// retention window, so a capture wrongly judged silent is not lost.
|
|
2798
3002
|
markRecoveredNoSpeech(quarantineDir, entries.length)
|
|
2799
3003
|
if (macCapture) closeMacRecording(sessionId)
|
|
3004
|
+
if (phoneCapture) closePhoneRecording(sessionId)
|
|
2800
3005
|
console.log(
|
|
2801
3006
|
`[meeting/orphans] ${sessionId} held no speech `
|
|
2802
3007
|
+ `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s) `
|
|
@@ -2806,7 +3011,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2806
3011
|
}
|
|
2807
3012
|
const recoveredChunks = results.map(item => ({
|
|
2808
3013
|
text: item.text,
|
|
2809
|
-
speaker: macCapture ? 'Ext' : 'Unknown',
|
|
3014
|
+
speaker: macCapture || phoneCapture ? 'Ext' : 'Unknown',
|
|
3015
|
+
...(phoneCapture ? { speakerPolicy: 'anonymous' as const, sourceType: PHONE_SOURCE_TYPE } : {}),
|
|
2810
3016
|
...(macCapture ? { speakerPolicy: 'high-confidence' as const } : {}),
|
|
2811
3017
|
elapsed: item.segment.startElapsed,
|
|
2812
3018
|
similarity: 0,
|
|
@@ -2822,7 +3028,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2822
3028
|
resolveCosOperationsDir(),
|
|
2823
3029
|
),
|
|
2824
3030
|
transcript,
|
|
2825
|
-
startTime: macCapture?.startTime ?? capture.startTime,
|
|
3031
|
+
startTime: macCapture?.startTime ?? phoneCapture?.startTime ?? capture.startTime,
|
|
2826
3032
|
durationMs: macCapture ? Math.max(...entries.map(e => e.chunk.elapsed + (e.chunk.audioDurationMs ?? 0))) : capture.durationMs,
|
|
2827
3033
|
chunks: recoveredChunks,
|
|
2828
3034
|
chunkEntries: recoveredChunks.map((chunk, position) => ({
|
|
@@ -2851,7 +3057,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2851
3057
|
})
|
|
2852
3058
|
} catch { /* display is best-effort */ }
|
|
2853
3059
|
await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
|
|
2854
|
-
if (cosOpsPipelineConfigured() && !macCapture) {
|
|
3060
|
+
if (cosOpsPipelineConfigured() && !macCapture && !phoneCapture) {
|
|
2855
3061
|
await handoffMeetingToOperations(saved.filepath)
|
|
2856
3062
|
} else {
|
|
2857
3063
|
// Orphan recovery is its own path and never reaches
|
|
@@ -2861,8 +3067,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2861
3067
|
// already holds the long-running orphan_recovery lease that COS
|
|
2862
3068
|
// Control surfaces and warns on before committing a drain.
|
|
2863
3069
|
await enrichStandaloneMeeting(saved.filepath, Date.now())
|
|
3070
|
+
if (macCapture) macFilingPath = saved.filepath
|
|
2864
3071
|
}
|
|
2865
|
-
if (!macCapture) triggerMeetingMergeRun('orphan_recovered')
|
|
3072
|
+
if (!macCapture && !phoneCapture) triggerMeetingMergeRun('orphan_recovered')
|
|
2866
3073
|
}).catch(error => {
|
|
2867
3074
|
// The quarantined audio is untouched on failure — retry stays possible
|
|
2868
3075
|
// until the retention clock clears it.
|
|
@@ -2880,7 +3087,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2880
3087
|
recoveringOrphans.delete(sessionId)
|
|
2881
3088
|
lease.release()
|
|
2882
3089
|
})
|
|
2883
|
-
|
|
3090
|
+
// As on the save path: a recovered Mac capture is filed only after the recovery has
|
|
3091
|
+
// finished and released its orphan_recovery lease, so a drain never waits on it.
|
|
3092
|
+
scheduleBackground(task.then(() => macFilingPath
|
|
3093
|
+
? fileFinishedMacRecording(sessionId, macFilingPath, 'meeting/orphans')
|
|
3094
|
+
: undefined))
|
|
2884
3095
|
})
|
|
2885
3096
|
|
|
2886
3097
|
return router
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PHONE_CAPTURE_ID } from '../lib/phone-recording.js'
|
|
1
2
|
// Meeting archive: COS operations tree when configured, else standalone recordings.
|
|
2
3
|
|
|
3
4
|
import { Router } from 'express'
|
|
@@ -73,13 +74,16 @@ export function withMeetingContext<T extends MeetingMeta>(meeting: T, source: Me
|
|
|
73
74
|
return { ...meeting, ...readOnlyContext() }
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
/**
|
|
77
|
+
/** Exact Mac identity; iPhone captures never satisfy this predicate. */
|
|
77
78
|
export function isMacCaptureId(sessionId: string | undefined): boolean {
|
|
78
79
|
return typeof sessionId === 'string' && MAC_CAPTURE_ID.test(sessionId)
|
|
79
80
|
}
|
|
81
|
+
function isStandaloneCaptureId(sessionId: string | undefined): boolean {
|
|
82
|
+
return typeof sessionId === 'string' && (MAC_CAPTURE_ID.test(sessionId) || PHONE_CAPTURE_ID.test(sessionId))
|
|
83
|
+
}
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
|
-
* Operations rows plus the store's Mac recordings (6.73.14), newest first by date and time, capped at `limit`. Only Mac
|
|
86
|
+
* Operations rows plus the store's Mac recordings (6.73.14), newest first by date and time, capped at `limit`. Only Mac and admitted phone
|
|
83
87
|
* rows are added: a G2 store row is the pipeline's to file, and its operations copy is the row. A Mac row is left out
|
|
84
88
|
* when an operations row holds its session, or when `held` does (the sessions merged scribes declare), the same rule
|
|
85
89
|
* the day count applies (imported-library-rows macStoreDayCounts). Operations rows are never deduplicated against each
|
|
@@ -90,7 +94,7 @@ export function withMacStoreRows(operations: MeetingMeta[], storeRows: MeetingMe
|
|
|
90
94
|
const mac = storeRows
|
|
91
95
|
// Keyed on the sidecar's session, as the save route keys the pipeline skip (MAC_CAPTURE_ID). The row's sourceType
|
|
92
96
|
// comes from the markdown's Capture source line instead, which an edited or older record can lack.
|
|
93
|
-
.filter(row =>
|
|
97
|
+
.filter(row => isStandaloneCaptureId(row.sessionId) && (!PHONE_CAPTURE_ID.test(row.sessionId!) || row.sourceType === 'iphone_microphone') && !sessions.has(row.sessionId) && !held.has(row.sessionId!))
|
|
94
98
|
.map(withStandaloneIdentity)
|
|
95
99
|
if (mac.length === 0) return operations
|
|
96
100
|
return [...operations, ...mac]
|
|
@@ -342,7 +346,7 @@ export function createMeetingsRouter(
|
|
|
342
346
|
// 6.73.14 (Miles 2026-10-15): routes/meeting.ts never hands a Mac recording to the pipeline (MAC_CAPTURE_ID),
|
|
343
347
|
// so on a pipeline Mac it lives only in this store and never appeared in Meetings; Control's hand-off read
|
|
344
348
|
// "Saved meeting not found yet" forever. The store's Mac rows join the operations rows here.
|
|
345
|
-
const meetings = withMacStoreRows(operationsMeetings, store.list({ ...listOptions, sessionMatch:
|
|
349
|
+
const meetings = withMacStoreRows(operationsMeetings, store.list({ ...listOptions, sessionMatch: isStandaloneCaptureId }), limit, declaredSessions)
|
|
346
350
|
// ONLY WHEN THE LIST SAW THE WHOLE MONTH. The row list is capped, and a capped
|
|
347
351
|
// list knows about fewer merged scribes than the month holds, so handing its
|
|
348
352
|
// sessions to an UNCAPPED day count would subtract too little and put the dot
|
|
@@ -356,7 +360,7 @@ export function createMeetingsRouter(
|
|
|
356
360
|
const operationsMonths = listCosOperationsMeetingMonths(domain)
|
|
357
361
|
const known = new Set(operationsMonths)
|
|
358
362
|
const macOnly = store.listMonths().filter(month => !known.has(month)
|
|
359
|
-
&& store.list({ month, domain, limit: 1, sessionMatch:
|
|
363
|
+
&& store.list({ month, domain, limit: 1, sessionMatch: isStandaloneCaptureId }).length > 0)
|
|
360
364
|
return macOnly.length ? uniqueSortedMonths([operationsMonths, macOnly]) : operationsMonths
|
|
361
365
|
})(),
|
|
362
366
|
// The rows above already read every scribe in this month and told us which
|