@gotcos/glasses-server 6.21.23 → 6.21.25
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
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
+
## 6.21.25
|
|
2
|
+
|
|
3
|
+
- `POST /meeting/:sessionId/confirm` — record that a human vouched for a label
|
|
4
|
+
the display floor demoted. The floor exists so the identifier cannot assert a
|
|
5
|
+
name it did not earn, but a person who was in the room is better evidence
|
|
6
|
+
than a cosine score, and there was no way to say so. A rename could not
|
|
7
|
+
express it: `relabelSidecarJson` rejects `from === to`. So the panel demoted
|
|
8
|
+
the row, instructed the reviewer to name it, and offered a candidate list
|
|
9
|
+
that excluded the very name they wanted.
|
|
10
|
+
|
|
11
|
+
A confirmation rewrites nothing — the sidecar already carries the label. It
|
|
12
|
+
records the vouch, and `reviewMeetingSpeakers` then reports the row as
|
|
13
|
+
asserted. Meeting-scoped like every other correction: vouching for a voice in
|
|
14
|
+
one room says nothing about a different room. Refuses with 409 if no chunk in
|
|
15
|
+
the meeting actually carries that label, so a typo cannot become a permanent
|
|
16
|
+
confirmation in an append-only ledger.
|
|
17
|
+
|
|
18
|
+
A confirmed row still shows its thrash caveat. The name is asserted; the
|
|
19
|
+
evidence that it swaps with someone else is not hidden.
|
|
20
|
+
|
|
21
|
+
- Fixed a latent trap found while building it: `readCorrections` validated
|
|
22
|
+
`phase` against a hardcoded list, so adding a phase to the TYPE made every
|
|
23
|
+
such row unusable at read time — the write succeeded, the read silently
|
|
24
|
+
dropped it, and no error surfaced anywhere. Phase validation now derives from
|
|
25
|
+
a single list with a narrowing guard.
|
|
26
|
+
|
|
27
|
+
## 6.21.24
|
|
28
|
+
|
|
29
|
+
- A leaked recording session can no longer block every restart. The maintenance
|
|
30
|
+
drain gate counted `sessions.size`, so a session whose phone dropped
|
|
31
|
+
mid-recording without sending a close pinned that count at 1 indefinitely and
|
|
32
|
+
Install, Repair, Restart and Update Server each drained it, timed out after
|
|
33
|
+
60s, and failed — with no user-visible reason. Hit twice on 2026-08-06
|
|
34
|
+
(6.21.20 and 6.21.22); the second phantom had been silent 54 minutes and the
|
|
35
|
+
only way through was to finalize it as a real meeting, which it was not.
|
|
36
|
+
|
|
37
|
+
The gate now counts only sessions active within the last 30 minutes. A stale
|
|
38
|
+
one is reported in status as `staleTranscriptionSessions` with its session id,
|
|
39
|
+
silent duration and chunk count, so a blocked operator can see the cause —
|
|
40
|
+
and `/api/meeting/orphans` reporting 0 can no longer coexist silently with a
|
|
41
|
+
held lock, since the two read different stores.
|
|
42
|
+
|
|
43
|
+
**Nothing is reaped.** The session, its chunks and its recoverability are
|
|
44
|
+
untouched; only its claim on the restart gate expires. The 30-minute window
|
|
45
|
+
sits far above the reasons a live recording legitimately goes quiet (a
|
|
46
|
+
backgrounded phone buffering to IndexedDB, a network drop) and far below the
|
|
47
|
+
4h durable-chunk retention.
|
|
48
|
+
|
|
49
|
+
Note for anyone reading the health payload: `oldestWorkStartedAt: null` does
|
|
50
|
+
NOT indicate a leak. `recording_session` reaches the gate through
|
|
51
|
+
`extraActiveByKind`, which never contributes a timestamp, so every recording
|
|
52
|
+
session reports null — healthy or not. Only `lastActivityAt` separates them.
|
|
53
|
+
|
|
1
54
|
## 6.21.23
|
|
2
55
|
|
|
3
56
|
- `GET /meeting/:sessionId/embeddings` — why each chunk was labelled the way it
|
package/package.json
CHANGED
|
@@ -35,7 +35,31 @@ export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
|
35
35
|
* `intent` goes down before any file is touched; `applied` or `failed` closes it.
|
|
36
36
|
* An unclosed intent is an incomplete correction, not a successful one.
|
|
37
37
|
*/
|
|
38
|
-
export type CorrectionPhase = 'intent' | 'applied' | 'failed'
|
|
38
|
+
export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
|
|
39
|
+
/**
|
|
40
|
+
* A human confirming the identifier was RIGHT about a label the display floor
|
|
41
|
+
* demoted.
|
|
42
|
+
*
|
|
43
|
+
* Distinct from a rename, and it has to be: `relabelSidecarJson` rejects
|
|
44
|
+
* `from === to`, so "yes, this really is Queen Ukaoma" cannot be expressed as a
|
|
45
|
+
* correction at all. The floor exists because a 0.56 match is not evidence — but
|
|
46
|
+
* a person who was in the room IS evidence, and there was no way to record it.
|
|
47
|
+
* The panel demoted the row, told the reviewer to name it, and then offered a
|
|
48
|
+
* list that deliberately excluded the one name they wanted.
|
|
49
|
+
*
|
|
50
|
+
* A confirmation rewrites NOTHING. The sidecar already carries the label; this
|
|
51
|
+
* only records that a human vouched for it, so the review stops presenting it
|
|
52
|
+
* as unearned. Scoped to one meeting like every other correction.
|
|
53
|
+
*/
|
|
54
|
+
export const CONFIRMATION_PHASE = 'confirmed' as const
|
|
55
|
+
|
|
56
|
+
/** Runtime counterpart of CorrectionPhase. Must stay in step with it. */
|
|
57
|
+
const VALID_PHASES = new Set<string>(['intent', 'applied', 'failed', 'confirmed'])
|
|
58
|
+
|
|
59
|
+
/** Narrowing guard, so the reader keeps its type safety with one phase list. */
|
|
60
|
+
function isCorrectionPhase(value: unknown): value is CorrectionPhase {
|
|
61
|
+
return typeof value === 'string' && VALID_PHASES.has(value)
|
|
62
|
+
}
|
|
39
63
|
|
|
40
64
|
export interface CorrectionSurfaces {
|
|
41
65
|
/** Chunks whose `speaker` changed in the sidecar. */
|
|
@@ -123,7 +147,11 @@ export function readCorrections(sessionId: string): CorrectionReadResult {
|
|
|
123
147
|
try {
|
|
124
148
|
const o = JSON.parse(line) as Record<string, unknown>
|
|
125
149
|
if (typeof o.id !== 'string' || typeof o.from !== 'string' || typeof o.to !== 'string') { unusable++; continue }
|
|
126
|
-
|
|
150
|
+
// Kept as one list so adding a phase to the TYPE cannot silently make
|
|
151
|
+
// every such row unusable at read time — which is exactly what happened
|
|
152
|
+
// when 'confirmed' was added: appendCorrection wrote it, this dropped it,
|
|
153
|
+
// and the confirmation vanished with no error anywhere.
|
|
154
|
+
if (!isCorrectionPhase(o.phase)) { unusable++; continue }
|
|
127
155
|
rows.push({
|
|
128
156
|
id: o.id,
|
|
129
157
|
phase: o.phase,
|
|
@@ -156,6 +184,22 @@ function isSurfaces(v: unknown): v is CorrectionSurfaces {
|
|
|
156
184
|
* and a meeting with a pending correction should be treated as possibly
|
|
157
185
|
* half-written rather than clean.
|
|
158
186
|
*/
|
|
187
|
+
/**
|
|
188
|
+
* Labels a human has confirmed for this meeting.
|
|
189
|
+
*
|
|
190
|
+
* Read by the speaker review to clear the display floor for exactly those
|
|
191
|
+
* labels. A confirmation is per-meeting and never global: vouching for a voice
|
|
192
|
+
* in one room says nothing about a different room, which is the same reasoning
|
|
193
|
+
* that makes every correction here meeting-scoped.
|
|
194
|
+
*/
|
|
195
|
+
export function confirmedLabels(sessionId: string): Set<string> {
|
|
196
|
+
const confirmed = new Set<string>()
|
|
197
|
+
for (const row of readCorrections(sessionId).rows) {
|
|
198
|
+
if (row.phase === 'confirmed') confirmed.add(row.to)
|
|
199
|
+
}
|
|
200
|
+
return confirmed
|
|
201
|
+
}
|
|
202
|
+
|
|
159
203
|
export function pendingCorrections(sessionId: string): CorrectionRow[] {
|
|
160
204
|
const { rows } = readCorrections(sessionId)
|
|
161
205
|
const closed = new Set(rows.filter(r => r.phase !== 'intent').map(r => r.id))
|
|
@@ -130,6 +130,8 @@ export interface VoiceReview {
|
|
|
130
130
|
meanRun: number
|
|
131
131
|
longestRun: number
|
|
132
132
|
isOwner: boolean
|
|
133
|
+
/** A human confirmed this label for this meeting; the floor is waived. */
|
|
134
|
+
confirmedByHuman: boolean
|
|
133
135
|
reliability: Reliability
|
|
134
136
|
/**
|
|
135
137
|
* Whether `label` may be shown to a human AS A NAME.
|
|
@@ -385,9 +387,21 @@ export function selectPhrases(
|
|
|
385
387
|
/** Build the whole review for one meeting's chunks. */
|
|
386
388
|
export function reviewMeetingSpeakers(
|
|
387
389
|
chunks: ReviewChunk[],
|
|
388
|
-
options: {
|
|
390
|
+
options: {
|
|
391
|
+
owner?: string
|
|
392
|
+
phrasesPerVoice?: number
|
|
393
|
+
durationMs?: number
|
|
394
|
+
/**
|
|
395
|
+
* Labels a human vouched for in THIS meeting. The floor is a guard against
|
|
396
|
+
* the identifier over-claiming; it was never meant to overrule a person who
|
|
397
|
+
* was in the room. A confirmed label asserts its name with no rewrite —
|
|
398
|
+
* the sidecar already carries it.
|
|
399
|
+
*/
|
|
400
|
+
confirmed?: Set<string>
|
|
401
|
+
} = {},
|
|
389
402
|
): MeetingSpeakerReview {
|
|
390
403
|
const owner = options.owner ?? 'Me'
|
|
404
|
+
const confirmed = options.confirmed ?? new Set<string>()
|
|
391
405
|
const limit = options.phrasesPerVoice ?? 3
|
|
392
406
|
const sequence = chunks.map(c => c.speaker ?? '')
|
|
393
407
|
// The caller's durationMs (the sidecar's own) is the meeting's true end.
|
|
@@ -439,6 +453,12 @@ export function reviewMeetingSpeakers(
|
|
|
439
453
|
const assertionBlockers: string[] = []
|
|
440
454
|
if (unattributed) {
|
|
441
455
|
assertionBlockers.push('no name was ever assigned to this voice')
|
|
456
|
+
} else if (confirmed.has(label)) {
|
|
457
|
+
// A human said this is them. Blockers stay OFF the list rather than being
|
|
458
|
+
// listed-then-overridden, because a reviewer reading "similarity 0.56
|
|
459
|
+
// below 0.65" under a name they personally confirmed would reasonably
|
|
460
|
+
// conclude the confirmation had not taken. `thrashesWith` still renders,
|
|
461
|
+
// so a genuinely mixed row is still visibly mixed.
|
|
442
462
|
} else if (label === owner) {
|
|
443
463
|
// The wearer is exempt. Their identity is established by wearing the
|
|
444
464
|
// device, not by cosine — and the owner is verified at exactly this floor
|
|
@@ -466,6 +486,8 @@ export function reviewMeetingSpeakers(
|
|
|
466
486
|
meanRun: Math.round(mean(runs) * 100) / 100,
|
|
467
487
|
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
468
488
|
isOwner: label === owner,
|
|
489
|
+
/** True when a human vouched for this label in this meeting. */
|
|
490
|
+
confirmedByHuman: confirmed.has(label),
|
|
469
491
|
reliability,
|
|
470
492
|
nameAsserted: assertionBlockers.length === 0,
|
|
471
493
|
assertionBlockers,
|
|
@@ -15,7 +15,7 @@ import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
|
15
15
|
import { serverMetrics } from '../lib/server-metrics.js'
|
|
16
16
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
17
17
|
import { getWhisperHealth } from '../lib/whisper-local.js'
|
|
18
|
-
import { getActiveTranscriptionSessionCount } from './transcribe-stream.js'
|
|
18
|
+
import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
|
|
19
19
|
|
|
20
20
|
export const maintenanceRouter = Router()
|
|
21
21
|
|
|
@@ -71,15 +71,21 @@ function operationCredentials(req: Request): MaintenanceOperationCredentials {
|
|
|
71
71
|
function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
72
72
|
const jobs = getQueryJobRuntimeHealth()
|
|
73
73
|
const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
|
|
74
|
+
// Only sessions still plausibly recording may hold the drain gate. A leaked
|
|
75
|
+
// session — phone dropped mid-recording, no close ever sent — otherwise pins
|
|
76
|
+
// this at 1 forever and every Install/Repair/Restart/Update drains, times out
|
|
77
|
+
// and fails with nothing to show the user. The stale ones are reported below
|
|
78
|
+
// rather than counted, and are never deleted here.
|
|
79
|
+
const sessionLiveness = getTranscriptionSessionLiveness()
|
|
74
80
|
const managed = managedRuntimeCapability()
|
|
75
81
|
const tracked = maintenanceLifecycle.snapshot(credentials, {
|
|
76
|
-
recording_session:
|
|
82
|
+
recording_session: sessionLiveness.live,
|
|
77
83
|
})
|
|
78
84
|
const untrackedDurableRuns = Math.max(0, jobs.activeRuns - (tracked.activeByKind.durable_query ?? 0))
|
|
79
85
|
const lifecycle = untrackedDurableRuns > 0
|
|
80
86
|
? maintenanceLifecycle.snapshot(credentials, {
|
|
81
87
|
durable_query_runtime: untrackedDurableRuns,
|
|
82
|
-
recording_session:
|
|
88
|
+
recording_session: sessionLiveness.live,
|
|
83
89
|
})
|
|
84
90
|
: tracked
|
|
85
91
|
return {
|
|
@@ -91,6 +97,13 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
|
91
97
|
bootId: serverMetrics.bootId,
|
|
92
98
|
activeJobs: jobs.activeRuns,
|
|
93
99
|
activeTranscriptionSessions,
|
|
100
|
+
// Split out so a blocked operator can SEE why, and so "0 orphans" can never
|
|
101
|
+
// again coexist with a held lock: the orphans endpoint reads the quarantine
|
|
102
|
+
// directory, this reads the in-memory session map, and they are different
|
|
103
|
+
// stores. A stale session is surfaced here and blocks nothing.
|
|
104
|
+
liveTranscriptionSessions: sessionLiveness.live,
|
|
105
|
+
staleTranscriptionSessions: sessionLiveness.stale,
|
|
106
|
+
staleTranscriptionSessionDetail: sessionLiveness.staleSessions,
|
|
94
107
|
shuttingDown: jobs.shuttingDown,
|
|
95
108
|
durableStoreState: jobs.store.state,
|
|
96
109
|
lifecycle,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { isSampleFromSession, untraceableSampleCount } from '../lib/training-aud
|
|
|
13
13
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
14
|
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
15
15
|
import { errMsg } from '../lib/utils.js'
|
|
16
|
+
import { confirmedLabels } from '../lib/meeting-corrections.js'
|
|
16
17
|
import {
|
|
17
18
|
extAudioChunkPath,
|
|
18
19
|
listExtAudioChunks,
|
|
@@ -724,6 +725,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
724
725
|
const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
725
726
|
const review = reviewMeetingSpeakers(withIndices, {
|
|
726
727
|
owner: getOwnerSpeakerLabel(),
|
|
728
|
+
// Labels a human already vouched for in this meeting. Without this the
|
|
729
|
+
// floor re-demotes a confirmed name on every reload, and the reviewer
|
|
730
|
+
// confirms the same voice forever.
|
|
731
|
+
confirmed: confirmedLabels(sessionId),
|
|
727
732
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
728
733
|
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
729
734
|
// max(elapsed) uses the START of the last chunk, which made the final
|
|
@@ -929,6 +934,87 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
929
934
|
// like and will keep matching. Removing only the label fixes the transcript and
|
|
930
935
|
// leaves the profile poisoned — the exact mechanism that grew the phantom
|
|
931
936
|
// "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
|
|
937
|
+
/**
|
|
938
|
+
* Confirm the identifier was RIGHT about a label the display floor demoted.
|
|
939
|
+
*
|
|
940
|
+
* Rewrites nothing. The sidecar already carries the label; this records that
|
|
941
|
+
* a human vouched for it so the review stops presenting it as unearned.
|
|
942
|
+
*
|
|
943
|
+
* This exists because "yes, that really is her" was inexpressible. A rename
|
|
944
|
+
* cannot say it — `relabelSidecarJson` rejects `from === to` — so the panel
|
|
945
|
+
* demoted the row, instructed the reviewer to name it, and then offered a
|
|
946
|
+
* candidate list that excluded the very name they wanted. The floor is a
|
|
947
|
+
* guard against the IDENTIFIER over-claiming; it was never meant to overrule
|
|
948
|
+
* a person who was in the room.
|
|
949
|
+
*
|
|
950
|
+
* Meeting-scoped, like every other correction: vouching for a voice in one
|
|
951
|
+
* room says nothing about a different room.
|
|
952
|
+
*/
|
|
953
|
+
router.post('/meeting/:sessionId/confirm', (req, res) => {
|
|
954
|
+
res.set('Cache-Control', 'private, no-store')
|
|
955
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
956
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
957
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
const label = typeof req.body?.label === 'string' ? req.body.label : ''
|
|
961
|
+
const bad = invalidLabelReason(label)
|
|
962
|
+
if (bad) {
|
|
963
|
+
res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Same resolution as GET /speakers and the relabel route, so the panel and
|
|
968
|
+
// the confirmation act on the same copy of the meeting.
|
|
969
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
970
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
971
|
+
: null
|
|
972
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
973
|
+
if (!operations && !saved) {
|
|
974
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
978
|
+
|
|
979
|
+
// Refuse to confirm a label the meeting does not actually carry. Otherwise
|
|
980
|
+
// a typo becomes a permanent confirmation for a speaker who was never here,
|
|
981
|
+
// and the ledger is append-only.
|
|
982
|
+
let carried = 0
|
|
983
|
+
try {
|
|
984
|
+
const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
985
|
+
const rows = Array.isArray(doc.chunks) ? doc.chunks : []
|
|
986
|
+
carried = rows.filter(r => r && typeof r === 'object'
|
|
987
|
+
&& (r as Record<string, unknown>).speaker === label).length
|
|
988
|
+
} catch {
|
|
989
|
+
res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
if (carried === 0) {
|
|
993
|
+
res.status(409).json({
|
|
994
|
+
error: `No chunk in this meeting is labelled "${label}"`,
|
|
995
|
+
reason: 'label_not_present',
|
|
996
|
+
})
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const ok = appendCorrection(sessionId, {
|
|
1001
|
+
id: `confirm-${Date.now()}`,
|
|
1002
|
+
phase: 'confirmed',
|
|
1003
|
+
at: new Date().toISOString(),
|
|
1004
|
+
// `from` and `to` are the same by definition — that is what makes this a
|
|
1005
|
+
// confirmation rather than a rename, and why relabel could not express it.
|
|
1006
|
+
from: label,
|
|
1007
|
+
to: label,
|
|
1008
|
+
chunks: [],
|
|
1009
|
+
scope: 'meeting',
|
|
1010
|
+
})
|
|
1011
|
+
if (!ok) {
|
|
1012
|
+
res.status(500).json({ error: 'Could not record the confirmation', reason: 'ledger_write_failed' })
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
res.json({ confirmed: true, label, segments: carried })
|
|
1016
|
+
})
|
|
1017
|
+
|
|
932
1018
|
router.post('/meeting/:sessionId/deattribute', (req, res) => {
|
|
933
1019
|
res.set('Cache-Control', 'private, no-store')
|
|
934
1020
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -342,6 +342,76 @@ export function getActiveTranscriptionSessionCount(): number {
|
|
|
342
342
|
return sessions.size
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* How long a session may go silent before it stops BLOCKING a restart.
|
|
347
|
+
*
|
|
348
|
+
* Not a retention policy and not a reap: the session, its chunks and its
|
|
349
|
+
* recoverability are untouched. This decides one thing — whether it still
|
|
350
|
+
* counts toward the maintenance drain gate.
|
|
351
|
+
*
|
|
352
|
+
* Sized against the reasons a LIVE recording legitimately goes quiet: the phone
|
|
353
|
+
* backgrounded and buffering to IndexedDB, a network drop, a long pause. Those
|
|
354
|
+
* run to minutes. It sits deliberately far below
|
|
355
|
+
* LOCAL_FIRST_MEETING_IDLE_RETENTION_MS (4h), which governs how long chunks stay
|
|
356
|
+
* recoverable and is far too long to hold a restart on.
|
|
357
|
+
*/
|
|
358
|
+
export const RECORDING_SESSION_STALE_MS = 30 * 60 * 1000
|
|
359
|
+
|
|
360
|
+
export interface TranscriptionSessionLiveness {
|
|
361
|
+
/** Sessions still plausibly recording. These block a restart. */
|
|
362
|
+
live: number
|
|
363
|
+
/** Sessions silent past the threshold. Surfaced, NOT counted, NOT deleted. */
|
|
364
|
+
stale: number
|
|
365
|
+
/** Which ones, least-silent first, so an operator can act on a name. */
|
|
366
|
+
staleSessions: Array<{ sessionId: string; silentForMs: number; chunks: number }>
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Split active sessions into live and stale.
|
|
371
|
+
*
|
|
372
|
+
* WHY THIS EXISTS. The maintenance gate counts `sessions.size` and blocks every
|
|
373
|
+
* restart while it is non-zero. A session that leaks — the phone drops
|
|
374
|
+
* mid-recording and never sends a close — pins that count at 1 indefinitely, so
|
|
375
|
+
* Install, Repair, Restart and Update Server all drain, time out and fail with
|
|
376
|
+
* no user-visible reason. Observed twice on 2026-08-06 (servers 6.21.20 and
|
|
377
|
+
* 6.21.22); the second phantom had been silent 54 minutes and the only way
|
|
378
|
+
* through was to finalize it as a real meeting, which it was not.
|
|
379
|
+
*
|
|
380
|
+
* THE DISCRIMINATOR IS `lastActivityAt`, NOT `oldestWorkStartedAt`. The obvious
|
|
381
|
+
* reading — "activeTotal >= 1 with oldestWorkStartedAt null means leaked" — is
|
|
382
|
+
* WRONG and would strand healthy recordings. `recording_session` reaches the
|
|
383
|
+
* gate through `extraActiveByKind` (routes/maintenance.ts), and the snapshot's
|
|
384
|
+
* `oldestStartedAtMs` loop walks only tracked `work` entries, so an extra count
|
|
385
|
+
* never contributes a timestamp. EVERY recording session therefore reports
|
|
386
|
+
* `oldestWorkStartedAt: null`, healthy or leaked. Only the session's own
|
|
387
|
+
* `lastActivityAt` tells them apart.
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* The live session map, for tests only.
|
|
391
|
+
*
|
|
392
|
+
* Exposed because the liveness split is pure logic over `lastActivityAt` but
|
|
393
|
+
* the map is module-private, and a test that cannot seed it can only assert the
|
|
394
|
+
* empty case. An earlier draft of the liveness test guarded on this symbol
|
|
395
|
+
* existing and silently no-opped four of its six cases — passing green while
|
|
396
|
+
* exercising nothing. Never guard a test on its own seam; make the seam real.
|
|
397
|
+
*/
|
|
398
|
+
export const __sessionsForTests = sessions
|
|
399
|
+
|
|
400
|
+
export function getTranscriptionSessionLiveness(now = Date.now()): TranscriptionSessionLiveness {
|
|
401
|
+
let live = 0
|
|
402
|
+
const staleSessions: TranscriptionSessionLiveness['staleSessions'] = []
|
|
403
|
+
for (const [sessionId, session] of sessions) {
|
|
404
|
+
const silentForMs = now - session.lastActivityAt
|
|
405
|
+
if (silentForMs >= RECORDING_SESSION_STALE_MS) {
|
|
406
|
+
staleSessions.push({ sessionId, silentForMs, chunks: session.chunks.length })
|
|
407
|
+
} else {
|
|
408
|
+
live++
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
staleSessions.sort((a, b) => a.silentForMs - b.silentForMs)
|
|
412
|
+
return { live, stale: staleSessions.length, staleSessions }
|
|
413
|
+
}
|
|
414
|
+
|
|
345
415
|
// Incremental chunk persistence — survive server restarts
|
|
346
416
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
347
417
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|