@gotcos/glasses-server 6.21.22 → 6.21.24
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,50 @@
|
|
|
1
|
+
## 6.21.24
|
|
2
|
+
|
|
3
|
+
- A leaked recording session can no longer block every restart. The maintenance
|
|
4
|
+
drain gate counted `sessions.size`, so a session whose phone dropped
|
|
5
|
+
mid-recording without sending a close pinned that count at 1 indefinitely and
|
|
6
|
+
Install, Repair, Restart and Update Server each drained it, timed out after
|
|
7
|
+
60s, and failed — with no user-visible reason. Hit twice on 2026-08-06
|
|
8
|
+
(6.21.20 and 6.21.22); the second phantom had been silent 54 minutes and the
|
|
9
|
+
only way through was to finalize it as a real meeting, which it was not.
|
|
10
|
+
|
|
11
|
+
The gate now counts only sessions active within the last 30 minutes. A stale
|
|
12
|
+
one is reported in status as `staleTranscriptionSessions` with its session id,
|
|
13
|
+
silent duration and chunk count, so a blocked operator can see the cause —
|
|
14
|
+
and `/api/meeting/orphans` reporting 0 can no longer coexist silently with a
|
|
15
|
+
held lock, since the two read different stores.
|
|
16
|
+
|
|
17
|
+
**Nothing is reaped.** The session, its chunks and its recoverability are
|
|
18
|
+
untouched; only its claim on the restart gate expires. The 30-minute window
|
|
19
|
+
sits far above the reasons a live recording legitimately goes quiet (a
|
|
20
|
+
backgrounded phone buffering to IndexedDB, a network drop) and far below the
|
|
21
|
+
4h durable-chunk retention.
|
|
22
|
+
|
|
23
|
+
Note for anyone reading the health payload: `oldestWorkStartedAt: null` does
|
|
24
|
+
NOT indicate a leak. `recording_session` reaches the gate through
|
|
25
|
+
`extraActiveByKind`, which never contributes a timestamp, so every recording
|
|
26
|
+
session reports null — healthy or not. Only `lastActivityAt` separates them.
|
|
27
|
+
|
|
28
|
+
## 6.21.23
|
|
29
|
+
|
|
30
|
+
- `GET /meeting/:sessionId/embeddings` — why each chunk was labelled the way it
|
|
31
|
+
was. Reads the per-chunk embeddings the pipeline has retained since 6.21.15
|
|
32
|
+
and scores each one against every enrolled profile now, returning the top
|
|
33
|
+
matches and the margin between the best two. Until this route that store had
|
|
34
|
+
**no production reader at all**: the data was collected for weeks and never
|
|
35
|
+
looked at.
|
|
36
|
+
|
|
37
|
+
The margin is the point. It separates "missed by 0.02 against one profile"
|
|
38
|
+
from "equidistant between three" — a fixable near-miss versus a genuinely
|
|
39
|
+
ambiguous voice — and the review panel cannot tell those apart today. On a
|
|
40
|
+
face-mounted microphone that distinction is most of the available signal.
|
|
41
|
+
|
|
42
|
+
Read-only, and deliberately does NOT return the raw 192-float vectors: ~1 KB
|
|
43
|
+
of base64 per chunk that means nothing to a reader. Whole-session reads are
|
|
44
|
+
capped (default 50, max 400) because each chunk is scored against every
|
|
45
|
+
profile. A retained-but-absent chunk is reported in `missing` rather than
|
|
46
|
+
dropped, and `retained:false` stays distinguishable from "scored, no match".
|
|
47
|
+
|
|
1
48
|
## 6.21.22
|
|
2
49
|
|
|
3
50
|
- The meeting Turbo preview is ON by default. `COS_WHISPER_MEETING_PREVIEW` is
|
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Why a chunk was labelled the way it was.
|
|
2
|
+
//
|
|
3
|
+
// `chunk-embedding-store.ts` has retained a per-chunk embedding for every
|
|
4
|
+
// identified chunk, for 14 days, since 6.21.15 — and until now NOTHING in
|
|
5
|
+
// production read it back. The reader helpers existed; no route called them.
|
|
6
|
+
//
|
|
7
|
+
// That store is the difference between a reviewer (human or agent) who can only
|
|
8
|
+
// restate what the panel already shows and one who can answer the question that
|
|
9
|
+
// actually matters on a face-mounted microphone: not "who is this", but "how
|
|
10
|
+
// close did we get, and to WHAT". A row that missed its match by 0.02 against
|
|
11
|
+
// one profile is a very different problem from a row sitting equidistant
|
|
12
|
+
// between three, and the panel cannot tell them apart today.
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS DELIBERATELY DOES NOT RETURN: the raw 192-float vectors. They are
|
|
15
|
+
// ~1 KB of base64 each, they mean nothing to a reader, and a 400-chunk meeting
|
|
16
|
+
// would be 400 KB of noise. Similarity against each enrolled profile is the
|
|
17
|
+
// diagnostic; the vector is just how it is computed.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
chunkEmbeddingsForIndices,
|
|
21
|
+
readChunkEmbeddings,
|
|
22
|
+
type ChunkEmbeddingRow,
|
|
23
|
+
} from './chunk-embedding-store.js'
|
|
24
|
+
import { rawCosineSimilarity, readVoiceProfiles } from './speaker-embeddings.js'
|
|
25
|
+
|
|
26
|
+
/** Profiles scored per chunk. More than this is noise in a review context. */
|
|
27
|
+
const TOP_MATCHES = 5
|
|
28
|
+
|
|
29
|
+
export interface ProfileMatch {
|
|
30
|
+
speaker: string
|
|
31
|
+
/** Best cosine against any embedding held for that profile. */
|
|
32
|
+
similarity: number
|
|
33
|
+
/** How many embeddings that profile holds, so a strong score against a
|
|
34
|
+
* 1-sample profile is not read as equal to one against 20. */
|
|
35
|
+
embeddings: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChunkDiagnostic {
|
|
39
|
+
chunk: number
|
|
40
|
+
/** The label the identifier chose live, which is what a correction corrects. */
|
|
41
|
+
chosen: string
|
|
42
|
+
/** The score it chose on, as recorded at capture time. */
|
|
43
|
+
chosenSimilarity: number
|
|
44
|
+
/** Best-scoring profiles NOW, recomputed against the current store — which
|
|
45
|
+
* can differ from capture time if the profile has been trained since. */
|
|
46
|
+
matches: ProfileMatch[]
|
|
47
|
+
/** Gap between the top two current matches. A small margin means the choice
|
|
48
|
+
* was nearly a coin flip, and that is invisible in the panel today. */
|
|
49
|
+
margin: number | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ChunkDiagnosticsResult {
|
|
53
|
+
sessionId: string
|
|
54
|
+
/** False when the store holds nothing for this session — aged out past the
|
|
55
|
+
* 14-day TTL, captured before 6.21.15, or embeddings disabled. Distinct from
|
|
56
|
+
* an empty result set so a caller never reads "no data" as "no match". */
|
|
57
|
+
retained: boolean
|
|
58
|
+
chunks: ChunkDiagnostic[]
|
|
59
|
+
/** Chunks asked for that the store does not hold. */
|
|
60
|
+
missing: number[]
|
|
61
|
+
/** Profiles the scores were computed against, so a reader can see the
|
|
62
|
+
* candidate pool rather than assume it. */
|
|
63
|
+
profileCount: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Score one embedding against every enrolled profile.
|
|
68
|
+
*
|
|
69
|
+
* Best-of rather than mean: a profile holds up to 20 embeddings spanning
|
|
70
|
+
* different rooms and microphones, and averaging them buries the one recorded
|
|
71
|
+
* in conditions like these.
|
|
72
|
+
*/
|
|
73
|
+
function scoreAgainstProfiles(embedding: Float32Array): ProfileMatch[] {
|
|
74
|
+
const store = readVoiceProfiles()
|
|
75
|
+
const scored: ProfileMatch[] = []
|
|
76
|
+
for (const profile of store.profiles) {
|
|
77
|
+
let best = -1
|
|
78
|
+
for (const candidate of profile.embeddings) {
|
|
79
|
+
// The profile store holds plain number[]; the chunk store holds
|
|
80
|
+
// Float32Array. Convert at the boundary rather than widening the
|
|
81
|
+
// similarity function, which is on the live identification hot path.
|
|
82
|
+
const value = rawCosineSimilarity(embedding, new Float32Array(candidate))
|
|
83
|
+
if (value > best) best = value
|
|
84
|
+
}
|
|
85
|
+
if (best > -1) {
|
|
86
|
+
scored.push({
|
|
87
|
+
speaker: profile.name,
|
|
88
|
+
similarity: Number(best.toFixed(4)),
|
|
89
|
+
embeddings: profile.embeddings.length,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
scored.sort((a, b) => b.similarity - a.similarity)
|
|
94
|
+
return scored.slice(0, TOP_MATCHES)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toDiagnostic(row: ChunkEmbeddingRow): ChunkDiagnostic {
|
|
98
|
+
const matches = scoreAgainstProfiles(row.embedding)
|
|
99
|
+
return {
|
|
100
|
+
chunk: row.i,
|
|
101
|
+
chosen: row.speaker,
|
|
102
|
+
chosenSimilarity: Number(row.similarity.toFixed(4)),
|
|
103
|
+
matches,
|
|
104
|
+
margin: matches.length >= 2
|
|
105
|
+
? Number((matches[0].similarity - matches[1].similarity).toFixed(4))
|
|
106
|
+
: null,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Diagnostics for specific chunks, or for the whole session when `indices` is
|
|
112
|
+
* empty.
|
|
113
|
+
*
|
|
114
|
+
* Bounded by `limit` because a long meeting holds hundreds of chunks and each
|
|
115
|
+
* one is scored against every profile — a whole-session call on a 400-chunk
|
|
116
|
+
* meeting with 77 profiles is 30,000 cosine comparisons. Fine to ask for, worth
|
|
117
|
+
* capping by default.
|
|
118
|
+
*/
|
|
119
|
+
export function chunkDiagnostics(
|
|
120
|
+
sessionId: string,
|
|
121
|
+
indices: number[] = [],
|
|
122
|
+
limit = 50,
|
|
123
|
+
): ChunkDiagnosticsResult {
|
|
124
|
+
const profileCount = readVoiceProfiles().profiles.length
|
|
125
|
+
|
|
126
|
+
if (indices.length > 0) {
|
|
127
|
+
const rows = chunkEmbeddingsForIndices(sessionId, indices)
|
|
128
|
+
const found = new Set(rows.map(r => r.i))
|
|
129
|
+
return {
|
|
130
|
+
sessionId,
|
|
131
|
+
retained: readChunkEmbeddings(sessionId).rows.length > 0,
|
|
132
|
+
chunks: rows.slice(0, limit).map(toDiagnostic),
|
|
133
|
+
missing: indices.filter(i => !found.has(i)),
|
|
134
|
+
profileCount,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const all = readChunkEmbeddings(sessionId).rows
|
|
139
|
+
return {
|
|
140
|
+
sessionId,
|
|
141
|
+
retained: all.length > 0,
|
|
142
|
+
chunks: all.slice(0, limit).map(toDiagnostic),
|
|
143
|
+
missing: [],
|
|
144
|
+
profileCount,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -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
|
@@ -11,6 +11,8 @@ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
|
11
11
|
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
13
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
|
+
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
15
|
+
import { errMsg } from '../lib/utils.js'
|
|
14
16
|
import {
|
|
15
17
|
extAudioChunkPath,
|
|
16
18
|
listExtAudioChunks,
|
|
@@ -1182,6 +1184,57 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1182
1184
|
sendAudioFile(res, path)
|
|
1183
1185
|
})
|
|
1184
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Why each chunk was labelled the way it was.
|
|
1189
|
+
*
|
|
1190
|
+
* Reads the per-chunk embeddings the pipeline has retained since 6.21.15 and
|
|
1191
|
+
* scores each one against every enrolled profile RIGHT NOW. Until this route
|
|
1192
|
+
* that store had no production reader at all — the data was collected and
|
|
1193
|
+
* never looked at.
|
|
1194
|
+
*
|
|
1195
|
+
* This is what lets a reviewer distinguish "missed by 0.02 against one
|
|
1196
|
+
* profile" from "equidistant between three", which is the difference between
|
|
1197
|
+
* a fixable near-miss and a genuinely ambiguous voice. On a face-mounted
|
|
1198
|
+
* microphone that distinction is most of the signal.
|
|
1199
|
+
*
|
|
1200
|
+
* Read-only. It scores and reports; it changes no profile and no meeting.
|
|
1201
|
+
*
|
|
1202
|
+
* ?chunks=4,17,23 specific chunks (omit for the whole session)
|
|
1203
|
+
* ?limit=50 cap, because each chunk is scored against every profile
|
|
1204
|
+
*/
|
|
1205
|
+
router.get('/meeting/:sessionId/embeddings', (req, res) => {
|
|
1206
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1207
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1208
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1209
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1210
|
+
return
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const rawChunks = String(req.query.chunks ?? '').trim()
|
|
1214
|
+
const indices: number[] = []
|
|
1215
|
+
if (rawChunks) {
|
|
1216
|
+
for (const part of rawChunks.split(',')) {
|
|
1217
|
+
const value = Number(part.trim())
|
|
1218
|
+
// Reject the whole request rather than silently scoring a subset: a
|
|
1219
|
+
// caller asking about chunk 17 must not get an answer about chunk 4.
|
|
1220
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1221
|
+
res.status(400).json({ error: `Invalid chunk index "${part.trim()}"`, reason: 'invalid_chunk_index' })
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
indices.push(value)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const rawLimit = Number(req.query.limit ?? 50)
|
|
1229
|
+
const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 400) : 50
|
|
1230
|
+
|
|
1231
|
+
try {
|
|
1232
|
+
res.json(chunkDiagnostics(sessionId, indices, limit))
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
res.status(500).json({ error: errMsg(error), reason: 'diagnostics_failed' })
|
|
1235
|
+
}
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1185
1238
|
/** What audio a meeting still has, so the panel can show play buttons only
|
|
1186
1239
|
* where they will work. */
|
|
1187
1240
|
router.get('/meeting/:sessionId/audio', (req, res) => {
|
|
@@ -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)
|