@gotcos/glasses-server 6.12.4 → 6.12.6
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 +32 -0
- package/package.json +1 -1
- package/server/lib/claude-run-ledger.ts +3 -2
- package/server/lib/codex-run-ledger.ts +3 -2
- package/server/lib/local-first-meetings-contract.ts +6 -0
- package/server/lib/token-audit.ts +3 -2
- package/server/lib/whisper-local.ts +261 -102
- package/server/routes/meeting.ts +29 -0
- package/server/routes/transcribe-stream.ts +214 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.12.6
|
|
4
|
+
|
|
5
|
+
Pairs with COS Glasses build 222 to harden local-first meeting recovery and
|
|
6
|
+
local Whisper supervision while preserving the existing public API surface.
|
|
7
|
+
|
|
8
|
+
- **Meeting work stays on its admitting Mac.** Capability-aware clients pin
|
|
9
|
+
upload, status, and save requests to one `serverInstanceId`; a mismatch fails
|
|
10
|
+
before audio is consumed. Legacy unpinned clients continue to work.
|
|
11
|
+
- **Receipt is not transcription.** Durable ledgers distinguish raw audio
|
|
12
|
+
receipt, completed ASR, and canonical transcript text. Silent chunks persist
|
|
13
|
+
as terminal empty completions, so replay does not rerun ASR or invent text.
|
|
14
|
+
- **Whisper recovery is single-owner.** Concurrent start/restart requests are
|
|
15
|
+
serialized and coalesced. COS reaps only a process tree proven to own the
|
|
16
|
+
configured model and port 8178, verifies the port is clear, and launches one
|
|
17
|
+
replacement. An unrelated Whisper process is never killed; uncertain
|
|
18
|
+
ownership fails closed.
|
|
19
|
+
- **Backward compatible.** Existing query, prompt recovery, media, display,
|
|
20
|
+
diagnostics, transcription, and legacy meeting contracts retain their prior
|
|
21
|
+
behavior. The new capability flags are additive.
|
|
22
|
+
|
|
23
|
+
## 6.12.5
|
|
24
|
+
|
|
25
|
+
Extends the file-permission hardening to the remaining append-only logs that
|
|
26
|
+
the launch review named.
|
|
27
|
+
|
|
28
|
+
- **Run ledgers and the token-audit log are private at the file level.** The
|
|
29
|
+
Claude and Codex run ledgers and the token-audit JSONL now create with mode
|
|
30
|
+
0600 and repair existing files with chmod, matching the session-log and
|
|
31
|
+
atomic-fs writers. They already sat under the 0700 data directory; this
|
|
32
|
+
closes the file-level bit for defense in depth and covers the token-audit log
|
|
33
|
+
specifically, which lives beside the data directory rather than inside it.
|
|
34
|
+
|
|
3
35
|
## 6.12.4
|
|
4
36
|
|
|
5
37
|
Completes the public launch security review with a constant-time token check.
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
4
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
5
5
|
import { dataPath } from './data-dir.js'
|
|
@@ -118,7 +118,8 @@ function appendEvent(event: ClaudeRunEvent): void {
|
|
|
118
118
|
try {
|
|
119
119
|
const path = getClaudeLedgerPath()
|
|
120
120
|
mkdirSync(dirname(path), { recursive: true })
|
|
121
|
-
appendFileSync(path, JSON.stringify(event) + '\n')
|
|
121
|
+
appendFileSync(path, JSON.stringify(event) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
122
|
+
chmodSync(path, 0o600)
|
|
122
123
|
} catch (err) {
|
|
123
124
|
console.warn('[claude-run-ledger] write skipped:', err)
|
|
124
125
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
4
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
5
5
|
import { cosBrainDir } from './launch-dir.js'
|
|
@@ -140,7 +140,8 @@ function appendEvent(event: CodexRunEvent): void {
|
|
|
140
140
|
try {
|
|
141
141
|
const path = getCodexLedgerPath()
|
|
142
142
|
mkdirSync(dirname(path), { recursive: true })
|
|
143
|
-
appendFileSync(path, JSON.stringify(event) + '\n')
|
|
143
|
+
appendFileSync(path, JSON.stringify(event) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
144
|
+
chmodSync(path, 0o600)
|
|
144
145
|
} catch (err) {
|
|
145
146
|
console.warn('[codex-run-ledger] write skipped:', err)
|
|
146
147
|
}
|
|
@@ -6,6 +6,10 @@ export interface LocalFirstMeetingsCapability {
|
|
|
6
6
|
serverInstanceId: string
|
|
7
7
|
idempotentSave: true
|
|
8
8
|
sessionStatus: true
|
|
9
|
+
/** Capability-admitted clients pin every upload/status/save to one Mac. */
|
|
10
|
+
pinnedServerIdentity: true
|
|
11
|
+
/** Status and upload receipts distinguish raw receive, ASR completion, and canonical text. */
|
|
12
|
+
asrCompletionStatus: true
|
|
9
13
|
retentionMs: number
|
|
10
14
|
}
|
|
11
15
|
|
|
@@ -19,6 +23,8 @@ export function localFirstMeetingsCapability(serverInstanceId: string | null): L
|
|
|
19
23
|
serverInstanceId,
|
|
20
24
|
idempotentSave: true,
|
|
21
25
|
sessionStatus: true,
|
|
26
|
+
pinnedServerIdentity: true,
|
|
27
|
+
asrCompletionStatus: true,
|
|
22
28
|
retentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
23
29
|
}
|
|
24
30
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Both Python (COS scripts) and TypeScript (G2 glasses) write to the same JSONL.
|
|
3
3
|
// No LLM calls. Pure file append.
|
|
4
4
|
|
|
5
|
-
import { appendFileSync } from 'node:fs'
|
|
5
|
+
import { appendFileSync, chmodSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
8
|
|
|
@@ -43,7 +43,8 @@ export function logTokenAudit(entry: TokenAuditEntry): void {
|
|
|
43
43
|
caller: entry.caller,
|
|
44
44
|
}
|
|
45
45
|
try {
|
|
46
|
-
appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n')
|
|
46
|
+
appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
47
|
+
chmodSync(AUDIT_FILE, 0o600)
|
|
47
48
|
} catch {
|
|
48
49
|
// Never let logging break the actual call
|
|
49
50
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// 2. whisper-cli (spawned per request, model loaded from disk) → ~500-700ms
|
|
7
7
|
// 3. OpenAI API (cloud, handled by transcribe.ts) → ~1000-3000ms
|
|
8
8
|
|
|
9
|
-
import { spawn,
|
|
9
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
10
|
+
import type { ChildProcess } from 'node:child_process'
|
|
10
11
|
import { writeFileSync, unlinkSync, existsSync } from 'node:fs'
|
|
11
12
|
import { join } from 'node:path'
|
|
12
13
|
import { homedir } from 'node:os'
|
|
@@ -142,14 +143,171 @@ const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
|
|
|
142
143
|
let cliAvailable = false
|
|
143
144
|
let serverAvailable = false
|
|
144
145
|
let serverProcess: ReturnType<typeof spawn> | null = null
|
|
146
|
+
const ownedServerChildren = new Set<ChildProcess>()
|
|
145
147
|
|
|
146
148
|
// Circuit breaker: track consecutive server failures to detect hung process
|
|
147
149
|
let serverConsecutiveFailures = 0
|
|
148
150
|
const SERVER_FAILURE_THRESHOLD = 3 // After 3 consecutive failures, auto-restart
|
|
149
|
-
let serverRestarting = false //
|
|
151
|
+
let serverRestarting = false // Exposed in the existing health shape
|
|
150
152
|
let serverStarting = false // Initial model load is not a circuit failure
|
|
153
|
+
let serverStartPromise: Promise<void> | null = null
|
|
154
|
+
let serverRestartPromise: Promise<WhisperRestartResult> | null = null
|
|
151
155
|
let serverHealthProbe: Promise<boolean> | null = null
|
|
152
156
|
|
|
157
|
+
interface ProcessEntry {
|
|
158
|
+
pid: number
|
|
159
|
+
ppid: number
|
|
160
|
+
command: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface WhisperRestartResult {
|
|
164
|
+
status: 'recovered' | 'failed'
|
|
165
|
+
error?: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
169
|
+
|
|
170
|
+
function listProcesses(): ProcessEntry[] {
|
|
171
|
+
// `command=` includes arguments, which lets us distinguish this COS-owned
|
|
172
|
+
// port/model signature from unrelated whisper-server instances.
|
|
173
|
+
const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], { encoding: 'utf8' })
|
|
174
|
+
return output.split('\n').flatMap(line => {
|
|
175
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
|
|
176
|
+
if (!match) return []
|
|
177
|
+
return [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }]
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function listeningPids(): number[] {
|
|
182
|
+
try {
|
|
183
|
+
const output = execFileSync(
|
|
184
|
+
'lsof',
|
|
185
|
+
['-nP', `-iTCP:${WHISPER_SERVER_PORT}`, '-sTCP:LISTEN', '-t'],
|
|
186
|
+
{ encoding: 'utf8' },
|
|
187
|
+
)
|
|
188
|
+
return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
// lsof uses exit 1 for "no matches". Anything else means we could not
|
|
191
|
+
// prove the port state, so startup must fail closed.
|
|
192
|
+
if (err?.status === 1) return []
|
|
193
|
+
throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${err?.message ?? err}`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isCosWhisperServerCommand(command: string): boolean {
|
|
198
|
+
const executable = /(?:^|\s)(?:\S*\/)?whisper-server(?:\s|$)/.test(command)
|
|
199
|
+
const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
|
|
200
|
+
return executable && configuredPort && command.includes(MODEL_PATH)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectDescendants(processes: ProcessEntry[], roots: Iterable<number>): Set<number> {
|
|
204
|
+
const descendants = new Set<number>(roots)
|
|
205
|
+
let changed = true
|
|
206
|
+
while (changed) {
|
|
207
|
+
changed = false
|
|
208
|
+
for (const entry of processes) {
|
|
209
|
+
if (!descendants.has(entry.pid) && descendants.has(entry.ppid)) {
|
|
210
|
+
descendants.add(entry.pid)
|
|
211
|
+
changed = true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return descendants
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function signalPid(pid: number): void {
|
|
219
|
+
try {
|
|
220
|
+
process.kill(pid, 'SIGKILL')
|
|
221
|
+
} catch (err: any) {
|
|
222
|
+
if (err?.code !== 'ESRCH') throw err
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function waitForOwnedChildClose(child: ChildProcess, timeoutMs = 2_000): Promise<boolean> {
|
|
227
|
+
if (child.exitCode !== null || child.signalCode !== null) return true
|
|
228
|
+
return new Promise(resolve => {
|
|
229
|
+
let settled = false
|
|
230
|
+
const finish = (closed: boolean) => {
|
|
231
|
+
if (settled) return
|
|
232
|
+
settled = true
|
|
233
|
+
clearTimeout(timeout)
|
|
234
|
+
child.off('close', onClose)
|
|
235
|
+
resolve(closed)
|
|
236
|
+
}
|
|
237
|
+
const onClose = () => finish(true)
|
|
238
|
+
const timeout = setTimeout(() => finish(false), timeoutMs)
|
|
239
|
+
child.once('close', onClose)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Kill every whisper-server we own or can identify as stale, plus all of its
|
|
245
|
+
* descendants. Direct children are awaited so Node reaps them before another
|
|
246
|
+
* model process is allowed to bind the port.
|
|
247
|
+
*/
|
|
248
|
+
async function killAndReapWhisperProcesses(): Promise<void> {
|
|
249
|
+
serverAvailable = false
|
|
250
|
+
|
|
251
|
+
for (let round = 0; round < 3; round++) {
|
|
252
|
+
const processes = listProcesses()
|
|
253
|
+
const ownedPids = [...ownedServerChildren]
|
|
254
|
+
.map(child => child.pid)
|
|
255
|
+
.filter((pid): pid is number => typeof pid === 'number')
|
|
256
|
+
const staleWhisperPids = processes
|
|
257
|
+
.filter(entry => isCosWhisperServerCommand(entry.command))
|
|
258
|
+
.map(entry => entry.pid)
|
|
259
|
+
const targets = collectDescendants(processes, [...ownedPids, ...staleWhisperPids])
|
|
260
|
+
|
|
261
|
+
if (targets.size === 0 && ownedServerChildren.size === 0) break
|
|
262
|
+
|
|
263
|
+
// Descendants first prevents a model worker from surviving its supervisor.
|
|
264
|
+
const depth = new Map<number, number>()
|
|
265
|
+
const byPid = new Map(processes.map(entry => [entry.pid, entry]))
|
|
266
|
+
const getDepth = (pid: number): number => {
|
|
267
|
+
if (depth.has(pid)) return depth.get(pid)!
|
|
268
|
+
const parent = byPid.get(pid)?.ppid
|
|
269
|
+
const value = parent && targets.has(parent) ? getDepth(parent) + 1 : 0
|
|
270
|
+
depth.set(pid, value)
|
|
271
|
+
return value
|
|
272
|
+
}
|
|
273
|
+
const orderedTargets = [...targets].sort((a, b) => getDepth(b) - getDepth(a))
|
|
274
|
+
|
|
275
|
+
for (const pid of orderedTargets) {
|
|
276
|
+
const ownedChild = [...ownedServerChildren].find(child => child.pid === pid)
|
|
277
|
+
if (ownedChild) {
|
|
278
|
+
try { ownedChild.kill('SIGKILL') } catch { /* already exited */ }
|
|
279
|
+
} else {
|
|
280
|
+
signalPid(pid)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const closeResults = await Promise.all([...ownedServerChildren].map(child => waitForOwnedChildClose(child)))
|
|
285
|
+
if (closeResults.some(closed => !closed)) {
|
|
286
|
+
throw new Error('owned whisper-server child did not exit after SIGKILL')
|
|
287
|
+
}
|
|
288
|
+
await sleep(50)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const remaining = listProcesses().filter(entry => isCosWhisperServerCommand(entry.command))
|
|
292
|
+
if (remaining.length > 0) {
|
|
293
|
+
throw new Error(`stale whisper-server process(es) remain: ${remaining.map(entry => entry.pid).join(', ')}`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
serverProcess = null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function proveWhisperPortClear(): Promise<void> {
|
|
300
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
301
|
+
const pids = listeningPids()
|
|
302
|
+
if (pids.length === 0) return
|
|
303
|
+
if (attempt < 19) await sleep(250)
|
|
304
|
+
}
|
|
305
|
+
const pids = listeningPids()
|
|
306
|
+
throw new Error(
|
|
307
|
+
`whisper-server port ${WHISPER_SERVER_PORT} remains occupied${pids.length ? ` by PID(s) ${pids.join(', ')}` : ''}`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
153
311
|
// Check CLI availability at import time
|
|
154
312
|
try {
|
|
155
313
|
cliAvailable = existsSync(WHISPER_CLI) && existsSync(MODEL_PATH)
|
|
@@ -165,45 +323,38 @@ try {
|
|
|
165
323
|
* Called from index.ts at server boot. Non-blocking.
|
|
166
324
|
*/
|
|
167
325
|
export async function startWhisperServer(): Promise<void> {
|
|
168
|
-
if (
|
|
326
|
+
if (serverRestartPromise) {
|
|
327
|
+
const result = await serverRestartPromise
|
|
328
|
+
if (result.status === 'failed') {
|
|
329
|
+
throw new Error(result.error ?? 'whisper-server restart failed')
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (serverStartPromise) return serverStartPromise
|
|
334
|
+
if (serverAvailable && serverProcess) return
|
|
335
|
+
|
|
169
336
|
serverStarting = true
|
|
337
|
+
const operation = startWhisperServerAttempt()
|
|
338
|
+
serverStartPromise = operation
|
|
170
339
|
try {
|
|
171
|
-
await
|
|
340
|
+
await operation
|
|
172
341
|
} finally {
|
|
342
|
+
if (serverStartPromise === operation) serverStartPromise = null
|
|
173
343
|
serverStarting = false
|
|
174
344
|
}
|
|
175
345
|
}
|
|
176
346
|
|
|
177
|
-
async function startWhisperServerAttempt(): Promise<void> {
|
|
347
|
+
async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
|
|
178
348
|
if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
|
|
179
349
|
console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
|
|
180
350
|
return
|
|
181
351
|
}
|
|
182
352
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
console.log('[whisper-local] whisper-server already running on port', WHISPER_SERVER_PORT)
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
// Not running — kill any zombie processes before starting fresh
|
|
193
|
-
try {
|
|
194
|
-
execSync('pkill -9 -f "whisper-server"', { stdio: 'ignore' })
|
|
195
|
-
console.log('[whisper-local] Killed stale whisper-server processes')
|
|
196
|
-
} catch { /* none running */ }
|
|
197
|
-
|
|
198
|
-
// Wait for port to actually clear (up to 5s)
|
|
199
|
-
for (let i = 0; i < 10; i++) {
|
|
200
|
-
try {
|
|
201
|
-
execSync('lsof -i :8178 -t', { stdio: 'ignore' })
|
|
202
|
-
await new Promise(r => setTimeout(r, 500))
|
|
203
|
-
} catch {
|
|
204
|
-
break // Port clear
|
|
205
|
-
}
|
|
206
|
-
}
|
|
353
|
+
// The API process is the sole Whisper owner. Never adopt an untracked daemon:
|
|
354
|
+
// reap stale trees, then prove the fixed local port is free before spawning.
|
|
355
|
+
if (!preflightCompleted) {
|
|
356
|
+
await killAndReapWhisperProcesses()
|
|
357
|
+
await proveWhisperPortClear()
|
|
207
358
|
}
|
|
208
359
|
|
|
209
360
|
// Assemble startup args. VAD only attaches if the ggml model is actually on
|
|
@@ -237,51 +388,61 @@ async function startWhisperServerAttempt(): Promise<void> {
|
|
|
237
388
|
|
|
238
389
|
console.log('[whisper-local] Starting whisper-server...')
|
|
239
390
|
|
|
240
|
-
|
|
391
|
+
const child = spawn(WHISPER_SERVER, serverArgs, {
|
|
241
392
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
242
393
|
detached: false, // Dies with parent
|
|
243
394
|
})
|
|
395
|
+
serverProcess = child
|
|
396
|
+
ownedServerChildren.add(child)
|
|
244
397
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (res.ok) {
|
|
255
|
-
clearInterval(pollTimer)
|
|
256
|
-
serverAvailable = true
|
|
257
|
-
const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
258
|
-
console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
|
|
259
|
-
resolve()
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
// Not ready yet — keep polling
|
|
263
|
-
if (Date.now() - startTime > maxWaitMs) {
|
|
264
|
-
clearInterval(pollTimer)
|
|
265
|
-
console.warn(`[whisper-local] whisper-server startup timeout (${maxWaitMs / 1000}s) — using CLI fallback`)
|
|
266
|
-
resolve()
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}, pollIntervalMs)
|
|
270
|
-
|
|
271
|
-
serverProcess!.on('error', (err) => {
|
|
272
|
-
clearInterval(pollTimer)
|
|
273
|
-
console.error('[whisper-local] whisper-server failed to start:', err.message)
|
|
274
|
-
resolve()
|
|
275
|
-
})
|
|
398
|
+
child.once('close', (code) => {
|
|
399
|
+
ownedServerChildren.delete(child)
|
|
400
|
+
if (serverProcess !== child) return
|
|
401
|
+
serverAvailable = false
|
|
402
|
+
serverProcess = null
|
|
403
|
+
if (code !== null && code !== 0) {
|
|
404
|
+
console.warn(`[whisper-local] whisper-server exited with code ${code}`)
|
|
405
|
+
}
|
|
406
|
+
})
|
|
276
407
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
408
|
+
// Wait for server to be ready — poll /health every 2s (large models take ~20s to load).
|
|
409
|
+
// Polls are sequential, so a slow health request cannot overlap the next one.
|
|
410
|
+
const maxWaitMs = 45_000
|
|
411
|
+
const pollIntervalMs = 2_000
|
|
412
|
+
const startTime = Date.now()
|
|
413
|
+
let spawnError: Error | null = null
|
|
414
|
+
let childClosed = false
|
|
415
|
+
child.once('error', err => { spawnError = err })
|
|
416
|
+
child.once('close', () => { childClosed = true })
|
|
417
|
+
|
|
418
|
+
while (Date.now() - startTime <= maxWaitMs && !spawnError && !childClosed) {
|
|
419
|
+
try {
|
|
420
|
+
const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
|
|
421
|
+
if (res.ok) {
|
|
422
|
+
serverAvailable = true
|
|
423
|
+
const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
424
|
+
console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
|
|
425
|
+
return
|
|
282
426
|
}
|
|
283
|
-
}
|
|
284
|
-
|
|
427
|
+
} catch {
|
|
428
|
+
// Model is still loading.
|
|
429
|
+
}
|
|
430
|
+
if (!spawnError && !childClosed && Date.now() - startTime <= maxWaitMs) {
|
|
431
|
+
await sleep(pollIntervalMs)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Event-listener assignment is opaque to TypeScript's control-flow analysis.
|
|
436
|
+
const caughtSpawnError = spawnError as Error | null
|
|
437
|
+
const failure = caughtSpawnError
|
|
438
|
+
? `whisper-server failed to start: ${caughtSpawnError.message}`
|
|
439
|
+
: childClosed
|
|
440
|
+
? 'whisper-server exited before becoming healthy'
|
|
441
|
+
: `whisper-server startup timeout (${maxWaitMs / 1000}s)`
|
|
442
|
+
console.error(`[whisper-local] ${failure} — reaping child and keeping local backend unavailable`)
|
|
443
|
+
await killAndReapWhisperProcesses()
|
|
444
|
+
await proveWhisperPortClear()
|
|
445
|
+
throw new Error(failure)
|
|
285
446
|
}
|
|
286
447
|
|
|
287
448
|
/**
|
|
@@ -671,7 +832,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
671
832
|
if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
|
|
672
833
|
console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
|
|
673
834
|
// Non-blocking restart in background
|
|
674
|
-
restartWhisperServer()
|
|
835
|
+
void restartWhisperServer()
|
|
675
836
|
} else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
|
|
676
837
|
console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
|
|
677
838
|
}
|
|
@@ -687,7 +848,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
687
848
|
serverConsecutiveFailures++
|
|
688
849
|
if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
|
|
689
850
|
console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
|
|
690
|
-
restartWhisperServer()
|
|
851
|
+
void restartWhisperServer()
|
|
691
852
|
}
|
|
692
853
|
|
|
693
854
|
// Throw so the caller applies the configured recovery policy. CLI is
|
|
@@ -700,48 +861,46 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
700
861
|
* Non-blocking — runs in background while callers preserve audio or apply the
|
|
701
862
|
* explicitly configured fallback policy.
|
|
702
863
|
*/
|
|
703
|
-
async function restartWhisperServer(): Promise<
|
|
704
|
-
if (
|
|
705
|
-
serverRestarting = true
|
|
864
|
+
export async function restartWhisperServer(): Promise<WhisperRestartResult> {
|
|
865
|
+
if (serverRestartPromise) return serverRestartPromise
|
|
706
866
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
try { serverProcess.kill('SIGKILL') } catch {}
|
|
711
|
-
serverProcess = null
|
|
712
|
-
}
|
|
713
|
-
// Also kill any zombie processes
|
|
867
|
+
serverRestarting = true
|
|
868
|
+
const priorStart = serverStartPromise
|
|
869
|
+
const operation = (async (): Promise<WhisperRestartResult> => {
|
|
714
870
|
try {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
for (let i = 0; i < 6; i++) {
|
|
720
|
-
try {
|
|
721
|
-
execSync('lsof -i :8178 -t', { stdio: 'ignore' })
|
|
722
|
-
await new Promise(r => setTimeout(r, 500))
|
|
723
|
-
} catch {
|
|
724
|
-
break
|
|
871
|
+
// A restart requested during model load runs immediately after that single
|
|
872
|
+
// start attempt completes, then owns the lifecycle until recovery finishes.
|
|
873
|
+
if (priorStart) {
|
|
874
|
+
try { await priorStart } catch { /* restart performs its own clean recovery */ }
|
|
725
875
|
}
|
|
726
|
-
}
|
|
727
876
|
|
|
728
|
-
|
|
729
|
-
|
|
877
|
+
await killAndReapWhisperProcesses()
|
|
878
|
+
await proveWhisperPortClear()
|
|
730
879
|
|
|
731
|
-
|
|
880
|
+
console.log('[whisper-local] Restarting whisper-server (model load ~20s)...')
|
|
881
|
+
await startWhisperServerAttempt(true)
|
|
882
|
+
if (!serverAvailable) {
|
|
883
|
+
throw new Error('whisper-server did not become healthy')
|
|
884
|
+
}
|
|
732
885
|
serverConsecutiveFailures = 0
|
|
733
886
|
console.log('[whisper-local] Server restarted successfully — circuit breaker CLOSED')
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
//
|
|
887
|
+
return { status: 'recovered' }
|
|
888
|
+
} catch (err: any) {
|
|
889
|
+
serverAvailable = false
|
|
890
|
+
// Preserve the existing retry cadence: one failed recovery consumes this
|
|
891
|
+
// breaker cycle, and the next three failed calls may request one new cycle.
|
|
738
892
|
serverConsecutiveFailures = 0
|
|
739
|
-
|
|
893
|
+
const message = err?.message ?? String(err)
|
|
894
|
+
console.error(`[whisper-local] Server restart error: ${message} — will retry after next 3 failures`)
|
|
895
|
+
return { status: 'failed', error: message }
|
|
740
896
|
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
|
|
897
|
+
})()
|
|
898
|
+
|
|
899
|
+
serverRestartPromise = operation
|
|
900
|
+
try {
|
|
901
|
+
return await operation
|
|
744
902
|
} finally {
|
|
903
|
+
if (serverRestartPromise === operation) serverRestartPromise = null
|
|
745
904
|
serverRestarting = false
|
|
746
905
|
}
|
|
747
906
|
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -120,6 +120,16 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
function suppliedServerPin(
|
|
124
|
+
req: { get: (name: string) => string | undefined },
|
|
125
|
+
fallback: unknown,
|
|
126
|
+
): string | null {
|
|
127
|
+
const header = req.get('X-COS-Server-Instance')?.trim() ?? ''
|
|
128
|
+
const secondary = typeof fallback === 'string' ? fallback.trim() : ''
|
|
129
|
+
if (header && secondary && header !== secondary) return '__conflict__'
|
|
130
|
+
return header || secondary || null
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
|
|
124
134
|
const store = deps.store ?? getMeetingStore()
|
|
125
135
|
const sessions = deps.sessions ?? defaultSessionSource
|
|
@@ -140,6 +150,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
140
150
|
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
141
151
|
return
|
|
142
152
|
}
|
|
153
|
+
const pin = suppliedServerPin(req, req.query.serverInstanceId)
|
|
154
|
+
if (pin && pin !== serverInstanceId) {
|
|
155
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
156
|
+
return
|
|
157
|
+
}
|
|
143
158
|
const saved = store.findBySessionId(sessionId)
|
|
144
159
|
const live = getMeetingSessionStatus(sessionId)
|
|
145
160
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -149,6 +164,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
149
164
|
serverInstanceId,
|
|
150
165
|
receivedRanges: live.receivedRanges,
|
|
151
166
|
receivedCount: live.receivedCount,
|
|
167
|
+
asrCompletedRanges: live.asrCompletedRanges,
|
|
168
|
+
asrCompletedCount: live.asrCompletedCount,
|
|
169
|
+
canonicalRanges: live.canonicalRanges,
|
|
170
|
+
canonicalCount: live.canonicalCount,
|
|
152
171
|
maxChunkIndex: live.maxChunkIndex,
|
|
153
172
|
lastActivityAt: live.lastActivityAt,
|
|
154
173
|
retainedUntil: saved ? null : live.retainedUntil,
|
|
@@ -177,6 +196,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
177
196
|
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
178
197
|
return
|
|
179
198
|
}
|
|
199
|
+
const serverInstanceId = getServerInstanceId()
|
|
200
|
+
if (!serverInstanceId) {
|
|
201
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
205
|
+
if (pin && pin !== serverInstanceId) {
|
|
206
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
207
|
+
return
|
|
208
|
+
}
|
|
180
209
|
|
|
181
210
|
// A response can be lost after both durable files were committed. Find
|
|
182
211
|
// the sidecar by session ID so client retry/restart is idempotent.
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
retainedUntilIso,
|
|
43
43
|
type IndexRange,
|
|
44
44
|
} from '../lib/local-first-meetings-contract.js'
|
|
45
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
45
46
|
|
|
46
47
|
function ensurePrivateDirectory(path: string): void {
|
|
47
48
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -209,6 +210,10 @@ interface TranscriptSession {
|
|
|
209
210
|
// never arrived = a chunk lost in transit) is the only thing flagged as a gap.
|
|
210
211
|
// Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
|
|
211
212
|
receivedIndices?: number[]
|
|
213
|
+
/** Durable ASR terminal outcomes. Raw receive alone never implies completion. */
|
|
214
|
+
asrCompletedIndices?: number[]
|
|
215
|
+
/** Exact replay records for silent/filtered ASR completions. */
|
|
216
|
+
emptyCompletions?: Record<string, EmptyTranscriptCompletion>
|
|
212
217
|
maxChunkIndex?: number
|
|
213
218
|
/** Persisted idle-retention clock. Meeting date/duration still use startTime. */
|
|
214
219
|
lastActivityAt: number
|
|
@@ -226,10 +231,37 @@ interface ClosedTranscriptSession {
|
|
|
226
231
|
closedAt: number
|
|
227
232
|
lastActivityAt: number
|
|
228
233
|
receivedIndices: number[]
|
|
234
|
+
asrCompletedIndices: number[]
|
|
235
|
+
canonicalIndices: number[]
|
|
229
236
|
maxChunkIndex: number
|
|
230
237
|
reason: 'saved' | 'expired' | 'closed'
|
|
231
238
|
}
|
|
232
239
|
|
|
240
|
+
interface EmptyTranscriptCompletion {
|
|
241
|
+
text: ''
|
|
242
|
+
speaker: string
|
|
243
|
+
elapsed: number
|
|
244
|
+
backend?: string
|
|
245
|
+
asrProvider?: string
|
|
246
|
+
fallbackReason?: string
|
|
247
|
+
audioSha256?: string
|
|
248
|
+
canonical: false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface StreamChunkCompletionResponse {
|
|
252
|
+
text: string
|
|
253
|
+
speaker: string
|
|
254
|
+
chunkIndex: number
|
|
255
|
+
elapsed: number
|
|
256
|
+
sessionId: string
|
|
257
|
+
serverInstanceId: string
|
|
258
|
+
asrCompleted: true
|
|
259
|
+
canonical: boolean
|
|
260
|
+
backend?: string
|
|
261
|
+
asrProvider?: string
|
|
262
|
+
fallbackReason?: string
|
|
263
|
+
}
|
|
264
|
+
|
|
233
265
|
const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
|
|
234
266
|
|
|
235
267
|
// Incremental chunk persistence — survive server restarts
|
|
@@ -249,6 +281,8 @@ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
|
|
|
249
281
|
closedAt: value,
|
|
250
282
|
lastActivityAt: value,
|
|
251
283
|
receivedIndices: [],
|
|
284
|
+
asrCompletedIndices: [],
|
|
285
|
+
canonicalIndices: [],
|
|
252
286
|
maxChunkIndex: -1,
|
|
253
287
|
reason: 'closed',
|
|
254
288
|
}
|
|
@@ -269,8 +303,26 @@ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
|
|
|
269
303
|
const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
|
|
270
304
|
? raw.maxChunkIndex
|
|
271
305
|
: (receivedIndices.at(-1) ?? -1)
|
|
306
|
+
const asrCompletedIndices = Array.isArray(raw.asrCompletedIndices)
|
|
307
|
+
? Array.from(new Set(raw.asrCompletedIndices.filter(
|
|
308
|
+
(entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0,
|
|
309
|
+
))).sort((a, b) => a - b)
|
|
310
|
+
: []
|
|
311
|
+
const canonicalIndices = Array.isArray(raw.canonicalIndices)
|
|
312
|
+
? Array.from(new Set(raw.canonicalIndices.filter(
|
|
313
|
+
(entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0,
|
|
314
|
+
))).sort((a, b) => a - b)
|
|
315
|
+
: []
|
|
272
316
|
const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
|
|
273
|
-
normalized[id] = {
|
|
317
|
+
normalized[id] = {
|
|
318
|
+
closedAt,
|
|
319
|
+
lastActivityAt,
|
|
320
|
+
receivedIndices,
|
|
321
|
+
asrCompletedIndices,
|
|
322
|
+
canonicalIndices,
|
|
323
|
+
maxChunkIndex,
|
|
324
|
+
reason,
|
|
325
|
+
}
|
|
274
326
|
}
|
|
275
327
|
return normalized
|
|
276
328
|
} catch {
|
|
@@ -289,6 +341,8 @@ function persistClosedSessions(): void {
|
|
|
289
341
|
closedAt: now,
|
|
290
342
|
lastActivityAt: now,
|
|
291
343
|
receivedIndices: [],
|
|
344
|
+
asrCompletedIndices: [],
|
|
345
|
+
canonicalIndices: [],
|
|
292
346
|
maxChunkIndex: -1,
|
|
293
347
|
reason: 'closed',
|
|
294
348
|
}
|
|
@@ -345,6 +399,8 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
345
399
|
chunks: chunksIndexed.map(e => e.c),
|
|
346
400
|
chunksIndexed,
|
|
347
401
|
receivedIndices: session.receivedIndices ?? [],
|
|
402
|
+
asrCompletedIndices: session.asrCompletedIndices ?? [],
|
|
403
|
+
emptyCompletions: session.emptyCompletions ?? {},
|
|
348
404
|
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
349
405
|
providerCandidates: session.providerCandidates ?? {},
|
|
350
406
|
})
|
|
@@ -387,10 +443,10 @@ function recoverSessions(): void {
|
|
|
387
443
|
const chunks: TranscriptChunk[] = []
|
|
388
444
|
if (indexed) {
|
|
389
445
|
for (const e of indexed) {
|
|
390
|
-
if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = e.c
|
|
446
|
+
if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = { ...e.c, canonical: true }
|
|
391
447
|
}
|
|
392
448
|
} else if (legacy) {
|
|
393
|
-
for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = legacy[k]
|
|
449
|
+
for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = { ...legacy[k], canonical: true }
|
|
394
450
|
}
|
|
395
451
|
// Restore the received-index ledger. A legacy file (pre-feature)
|
|
396
452
|
// has no ledger and no way to know whether a chunk was truly lost,
|
|
@@ -415,12 +471,36 @@ function recoverSessions(): void {
|
|
|
415
471
|
for (let k = 0; k <= maxStored; k++) receivedIndices.push(k)
|
|
416
472
|
maxChunkIndex = maxStored
|
|
417
473
|
}
|
|
474
|
+
const emptyCompletions: Record<string, EmptyTranscriptCompletion> = {}
|
|
475
|
+
if (data.emptyCompletions && typeof data.emptyCompletions === 'object') {
|
|
476
|
+
for (const [key, value] of Object.entries(data.emptyCompletions as Record<string, unknown>)) {
|
|
477
|
+
if (!/^\d+$/.test(key) || !value || typeof value !== 'object') continue
|
|
478
|
+
const item = value as Partial<EmptyTranscriptCompletion>
|
|
479
|
+
if (item.text !== '' || typeof item.speaker !== 'string' || typeof item.elapsed !== 'number') continue
|
|
480
|
+
emptyCompletions[key] = { ...item, text: '', canonical: false } as EmptyTranscriptCompletion
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const canonical: number[] = []
|
|
484
|
+
for (let k = 0; k < chunks.length; k++) if (chunks[k]?.text) canonical.push(k)
|
|
485
|
+
const asrCompletedIndices = Array.isArray(data.asrCompletedIndices)
|
|
486
|
+
? Array.from(new Set(
|
|
487
|
+
(data.asrCompletedIndices as unknown[]).filter(
|
|
488
|
+
(n): n is number => Number.isInteger(n) && (n as number) >= 0,
|
|
489
|
+
),
|
|
490
|
+
)).sort((a, b) => a - b)
|
|
491
|
+
: []
|
|
492
|
+
// Canonical chunks and durable empty completion records are always
|
|
493
|
+
// terminal ASR outcomes. Never infer completion from raw receive.
|
|
494
|
+
for (const index of canonical) insertSortedUnique(asrCompletedIndices, index)
|
|
495
|
+
for (const key of Object.keys(emptyCompletions)) insertSortedUnique(asrCompletedIndices, Number(key))
|
|
418
496
|
const session: TranscriptSession = {
|
|
419
497
|
chunks,
|
|
420
498
|
startTime: data.startTime,
|
|
421
499
|
title: data.title || '',
|
|
422
500
|
lastActivityAt,
|
|
423
501
|
receivedIndices,
|
|
502
|
+
asrCompletedIndices,
|
|
503
|
+
emptyCompletions,
|
|
424
504
|
maxChunkIndex,
|
|
425
505
|
providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
|
|
426
506
|
? data.providerCandidates
|
|
@@ -464,10 +544,23 @@ function recoverSessions(): void {
|
|
|
464
544
|
const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
|
|
465
545
|
? data.maxChunkIndex
|
|
466
546
|
: (receivedIndices.at(-1) ?? -1)
|
|
547
|
+
const canonicalIndices = Array.isArray(data.chunksIndexed)
|
|
548
|
+
? (data.chunksIndexed as Array<{ i?: unknown; c?: TranscriptChunk }>)
|
|
549
|
+
.filter(entry => Number.isInteger(entry?.i) && (entry.i as number) >= 0 && entry.c?.text)
|
|
550
|
+
.map(entry => entry.i as number)
|
|
551
|
+
: []
|
|
552
|
+
const asrCompletedIndices = Array.isArray(data.asrCompletedIndices)
|
|
553
|
+
? Array.from(new Set((data.asrCompletedIndices as unknown[]).filter(
|
|
554
|
+
(value): value is number => Number.isInteger(value) && (value as number) >= 0,
|
|
555
|
+
))).sort((left, right) => left - right)
|
|
556
|
+
: [...canonicalIndices]
|
|
557
|
+
for (const index of canonicalIndices) insertSortedUnique(asrCompletedIndices, index)
|
|
467
558
|
closedSessionRecords.set(data.sessionId, {
|
|
468
559
|
closedAt: Date.now(),
|
|
469
560
|
lastActivityAt,
|
|
470
561
|
receivedIndices,
|
|
562
|
+
asrCompletedIndices,
|
|
563
|
+
canonicalIndices,
|
|
471
564
|
maxChunkIndex,
|
|
472
565
|
reason: 'expired',
|
|
473
566
|
})
|
|
@@ -580,10 +673,22 @@ export function getSession(sessionId: string): TranscriptSession {
|
|
|
580
673
|
let session = sessions.get(sessionId)
|
|
581
674
|
if (!session) {
|
|
582
675
|
const now = Date.now()
|
|
583
|
-
session = {
|
|
676
|
+
session = {
|
|
677
|
+
chunks: [],
|
|
678
|
+
startTime: now,
|
|
679
|
+
lastActivityAt: now,
|
|
680
|
+
title: '',
|
|
681
|
+
providerCandidates: {},
|
|
682
|
+
receivedIndices: [],
|
|
683
|
+
asrCompletedIndices: [],
|
|
684
|
+
emptyCompletions: {},
|
|
685
|
+
}
|
|
584
686
|
sessions.set(sessionId, session)
|
|
585
687
|
}
|
|
586
688
|
if (!session.providerCandidates) session.providerCandidates = {}
|
|
689
|
+
if (!session.receivedIndices) session.receivedIndices = []
|
|
690
|
+
if (!session.asrCompletedIndices) session.asrCompletedIndices = []
|
|
691
|
+
if (!session.emptyCompletions) session.emptyCompletions = {}
|
|
587
692
|
return session
|
|
588
693
|
}
|
|
589
694
|
|
|
@@ -616,6 +721,20 @@ function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): vo
|
|
|
616
721
|
}
|
|
617
722
|
}
|
|
618
723
|
|
|
724
|
+
function recordAsrCompleted(session: TranscriptSession, chunkIndex: number): void {
|
|
725
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
|
|
726
|
+
if (!session.asrCompletedIndices) session.asrCompletedIndices = []
|
|
727
|
+
insertSortedUnique(session.asrCompletedIndices, chunkIndex)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function canonicalIndices(session: TranscriptSession): number[] {
|
|
731
|
+
const result: number[] = []
|
|
732
|
+
for (let index = 0; index < session.chunks.length; index++) {
|
|
733
|
+
if (session.chunks[index]?.text) result.push(index)
|
|
734
|
+
}
|
|
735
|
+
return result
|
|
736
|
+
}
|
|
737
|
+
|
|
619
738
|
export interface TranscriptGapReport {
|
|
620
739
|
received: number // distinct chunk indices the server got
|
|
621
740
|
stored: number // chunks that survived filtering (have text)
|
|
@@ -768,6 +887,10 @@ export interface MeetingSessionStatusSnapshot {
|
|
|
768
887
|
state: 'active' | 'closed' | 'missing'
|
|
769
888
|
receivedRanges: IndexRange[]
|
|
770
889
|
receivedCount: number
|
|
890
|
+
asrCompletedRanges: IndexRange[]
|
|
891
|
+
asrCompletedCount: number
|
|
892
|
+
canonicalRanges: IndexRange[]
|
|
893
|
+
canonicalCount: number
|
|
771
894
|
maxChunkIndex: number
|
|
772
895
|
lastActivityAt: string | null
|
|
773
896
|
retainedUntil: string | null
|
|
@@ -777,10 +900,16 @@ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatus
|
|
|
777
900
|
const active = sessions.get(sessionId)
|
|
778
901
|
if (active) {
|
|
779
902
|
const received = active.receivedIndices ?? []
|
|
903
|
+
const asrCompleted = active.asrCompletedIndices ?? []
|
|
904
|
+
const canonical = canonicalIndices(active)
|
|
780
905
|
return {
|
|
781
906
|
state: 'active',
|
|
782
907
|
receivedRanges: compressIndexRanges(received),
|
|
783
908
|
receivedCount: received.length,
|
|
909
|
+
asrCompletedRanges: compressIndexRanges(asrCompleted),
|
|
910
|
+
asrCompletedCount: asrCompleted.length,
|
|
911
|
+
canonicalRanges: compressIndexRanges(canonical),
|
|
912
|
+
canonicalCount: canonical.length,
|
|
784
913
|
maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
|
|
785
914
|
lastActivityAt: new Date(active.lastActivityAt).toISOString(),
|
|
786
915
|
retainedUntil: retainedUntilIso(active.lastActivityAt),
|
|
@@ -792,6 +921,10 @@ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatus
|
|
|
792
921
|
state: 'closed',
|
|
793
922
|
receivedRanges: compressIndexRanges(closed.receivedIndices),
|
|
794
923
|
receivedCount: closed.receivedIndices.length,
|
|
924
|
+
asrCompletedRanges: compressIndexRanges(closed.asrCompletedIndices),
|
|
925
|
+
asrCompletedCount: closed.asrCompletedIndices.length,
|
|
926
|
+
canonicalRanges: compressIndexRanges(closed.canonicalIndices),
|
|
927
|
+
canonicalCount: closed.canonicalIndices.length,
|
|
795
928
|
maxChunkIndex: closed.maxChunkIndex,
|
|
796
929
|
lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
|
|
797
930
|
retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
|
|
@@ -801,6 +934,10 @@ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatus
|
|
|
801
934
|
state: 'missing',
|
|
802
935
|
receivedRanges: [],
|
|
803
936
|
receivedCount: 0,
|
|
937
|
+
asrCompletedRanges: [],
|
|
938
|
+
asrCompletedCount: 0,
|
|
939
|
+
canonicalRanges: [],
|
|
940
|
+
canonicalCount: 0,
|
|
804
941
|
maxChunkIndex: -1,
|
|
805
942
|
lastActivityAt: null,
|
|
806
943
|
retainedUntil: null,
|
|
@@ -815,11 +952,15 @@ function closeTranscriptSession(
|
|
|
815
952
|
const session = sessions.get(sessionId)
|
|
816
953
|
const now = Date.now()
|
|
817
954
|
const receivedIndices = [...(session?.receivedIndices ?? [])]
|
|
955
|
+
const asrCompletedIndices = [...(session?.asrCompletedIndices ?? [])]
|
|
956
|
+
const canonical = session ? canonicalIndices(session) : []
|
|
818
957
|
const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
|
|
819
958
|
closedSessionRecords.set(sessionId, {
|
|
820
959
|
closedAt: now,
|
|
821
960
|
lastActivityAt: session?.lastActivityAt ?? now,
|
|
822
961
|
receivedIndices,
|
|
962
|
+
asrCompletedIndices,
|
|
963
|
+
canonicalIndices: canonical,
|
|
823
964
|
maxChunkIndex,
|
|
824
965
|
reason,
|
|
825
966
|
})
|
|
@@ -866,6 +1007,23 @@ function makeHttpError(status: number, message: string, reason?: string): Error
|
|
|
866
1007
|
return err
|
|
867
1008
|
}
|
|
868
1009
|
|
|
1010
|
+
function assertPinnedServerIdentity(headerValue: unknown, queryValue: unknown): string {
|
|
1011
|
+
const headerPin = typeof headerValue === 'string' ? headerValue.trim() : ''
|
|
1012
|
+
const queryPin = typeof queryValue === 'string' ? queryValue.trim() : ''
|
|
1013
|
+
if (headerPin && queryPin && headerPin !== queryPin) {
|
|
1014
|
+
throw makeHttpError(409, 'conflicting server identity pins', 'server_instance_mismatch')
|
|
1015
|
+
}
|
|
1016
|
+
const supplied = headerPin || queryPin
|
|
1017
|
+
const serverInstanceId = getServerInstanceId()
|
|
1018
|
+
// Omission is the legacy route contract. Capability-admitted clients pin.
|
|
1019
|
+
if (!supplied) return serverInstanceId ?? ''
|
|
1020
|
+
if (!serverInstanceId) throw makeHttpError(503, 'server identity unavailable', 'server_identity_unavailable')
|
|
1021
|
+
if (supplied !== serverInstanceId) {
|
|
1022
|
+
throw makeHttpError(409, 'server identity mismatch', 'server_instance_mismatch')
|
|
1023
|
+
}
|
|
1024
|
+
return serverInstanceId
|
|
1025
|
+
}
|
|
1026
|
+
|
|
869
1027
|
function validateSessionId(sessionId: string): void {
|
|
870
1028
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
871
1029
|
throw makeHttpError(400, 'invalid sessionId', 'invalid_session_id')
|
|
@@ -1033,19 +1191,39 @@ function canonicalChunkResponse(
|
|
|
1033
1191
|
existing: TranscriptChunk,
|
|
1034
1192
|
sessionId: string,
|
|
1035
1193
|
chunkIndex: number,
|
|
1036
|
-
):
|
|
1194
|
+
): StreamChunkCompletionResponse {
|
|
1195
|
+
const serverInstanceId = getServerInstanceId() ?? ''
|
|
1037
1196
|
return {
|
|
1038
1197
|
text: existing.text,
|
|
1039
1198
|
speaker: existing.speaker,
|
|
1040
1199
|
chunkIndex,
|
|
1041
1200
|
elapsed: existing.elapsed,
|
|
1042
1201
|
sessionId,
|
|
1202
|
+
serverInstanceId,
|
|
1203
|
+
asrCompleted: true,
|
|
1204
|
+
canonical: true,
|
|
1043
1205
|
backend: existing.backend,
|
|
1044
1206
|
asrProvider: existing.asrProvider,
|
|
1045
1207
|
fallbackReason: existing.fallbackReason,
|
|
1046
1208
|
}
|
|
1047
1209
|
}
|
|
1048
1210
|
|
|
1211
|
+
function emptyChunkResponse(
|
|
1212
|
+
existing: EmptyTranscriptCompletion,
|
|
1213
|
+
sessionId: string,
|
|
1214
|
+
chunkIndex: number,
|
|
1215
|
+
): StreamChunkCompletionResponse {
|
|
1216
|
+
const serverInstanceId = getServerInstanceId() ?? ''
|
|
1217
|
+
return {
|
|
1218
|
+
...existing,
|
|
1219
|
+
chunkIndex,
|
|
1220
|
+
sessionId,
|
|
1221
|
+
serverInstanceId,
|
|
1222
|
+
asrCompleted: true,
|
|
1223
|
+
canonical: false,
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1049
1227
|
async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
|
|
1050
1228
|
// The worker owns reconciliation of stale health. Always attempt local ASR
|
|
1051
1229
|
// once; an availability snapshot must not divert meeting audio to cloud.
|
|
@@ -1152,7 +1330,7 @@ async function processStreamChunk(opts: {
|
|
|
1152
1330
|
clientElapsed?: number
|
|
1153
1331
|
/** Original client recording start, applied only before canonical chunks. */
|
|
1154
1332
|
startTimeOverride?: number
|
|
1155
|
-
}): Promise<
|
|
1333
|
+
}): Promise<StreamChunkCompletionResponse> {
|
|
1156
1334
|
const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
|
|
1157
1335
|
const tReq = performance.now()
|
|
1158
1336
|
validateSessionId(sessionId)
|
|
@@ -1166,6 +1344,7 @@ async function processStreamChunk(opts: {
|
|
|
1166
1344
|
session.startTime = opts.startTimeOverride
|
|
1167
1345
|
}
|
|
1168
1346
|
const alreadyCanonical = session.chunks[chunkIndex]
|
|
1347
|
+
const alreadyEmpty = session.emptyCompletions?.[String(chunkIndex)]
|
|
1169
1348
|
|
|
1170
1349
|
let candidateRecordKey: string | undefined
|
|
1171
1350
|
if (candidate) {
|
|
@@ -1186,9 +1365,10 @@ async function processStreamChunk(opts: {
|
|
|
1186
1365
|
|
|
1187
1366
|
// Do not let late duplicate/replayed candidates replace canonical raw audio.
|
|
1188
1367
|
// Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
|
|
1189
|
-
if (alreadyCanonical?.
|
|
1368
|
+
if (alreadyCanonical?.text) {
|
|
1190
1369
|
session.lastActivityAt = Date.now()
|
|
1191
1370
|
recordReceivedChunk(session, chunkIndex)
|
|
1371
|
+
recordAsrCompleted(session, chunkIndex)
|
|
1192
1372
|
if (candidate && candidateRecordKey) {
|
|
1193
1373
|
session.providerCandidates![candidateRecordKey].accepted =
|
|
1194
1374
|
alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
|
|
@@ -1198,6 +1378,13 @@ async function processStreamChunk(opts: {
|
|
|
1198
1378
|
persistSessionRequired(sessionId)
|
|
1199
1379
|
return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
|
|
1200
1380
|
}
|
|
1381
|
+
if (alreadyEmpty) {
|
|
1382
|
+
session.lastActivityAt = Date.now()
|
|
1383
|
+
recordReceivedChunk(session, chunkIndex)
|
|
1384
|
+
recordAsrCompleted(session, chunkIndex)
|
|
1385
|
+
persistSessionRequired(sessionId)
|
|
1386
|
+
return emptyChunkResponse(alreadyEmpty, sessionId, chunkIndex)
|
|
1387
|
+
}
|
|
1201
1388
|
|
|
1202
1389
|
await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
|
|
1203
1390
|
// Commit the received-index ledger only after the canonical raw WAV is
|
|
@@ -1276,8 +1463,21 @@ async function processStreamChunk(opts: {
|
|
|
1276
1463
|
session.providerCandidates[candidateRecordKey].accepted = false
|
|
1277
1464
|
session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
|
|
1278
1465
|
}
|
|
1466
|
+
const emptyCompletion: EmptyTranscriptCompletion = {
|
|
1467
|
+
text: '',
|
|
1468
|
+
speaker: clientSpeaker,
|
|
1469
|
+
elapsed,
|
|
1470
|
+
backend,
|
|
1471
|
+
asrProvider,
|
|
1472
|
+
fallbackReason: sanitized.fallbackReason || fallbackReason,
|
|
1473
|
+
audioSha256,
|
|
1474
|
+
canonical: false,
|
|
1475
|
+
}
|
|
1476
|
+
session.emptyCompletions ??= {}
|
|
1477
|
+
session.emptyCompletions[String(chunkIndex)] = emptyCompletion
|
|
1478
|
+
recordAsrCompleted(session, chunkIndex)
|
|
1279
1479
|
persistSessionRequired(sessionId)
|
|
1280
|
-
return
|
|
1480
|
+
return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
|
|
1281
1481
|
}
|
|
1282
1482
|
|
|
1283
1483
|
const chunk: TranscriptChunk = {
|
|
@@ -1296,7 +1496,7 @@ async function processStreamChunk(opts: {
|
|
|
1296
1496
|
canonical: true,
|
|
1297
1497
|
}
|
|
1298
1498
|
const finalExisting = session.chunks[chunkIndex]
|
|
1299
|
-
if (finalExisting?.
|
|
1499
|
+
if (finalExisting?.text) {
|
|
1300
1500
|
if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
|
|
1301
1501
|
session.providerCandidates[candidateRecordKey].accepted =
|
|
1302
1502
|
finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
|
|
@@ -1304,10 +1504,12 @@ async function processStreamChunk(opts: {
|
|
|
1304
1504
|
session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
1305
1505
|
}
|
|
1306
1506
|
session.lastActivityAt = Date.now()
|
|
1507
|
+
recordAsrCompleted(session, chunkIndex)
|
|
1307
1508
|
persistSessionRequired(sessionId)
|
|
1308
1509
|
return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
|
|
1309
1510
|
}
|
|
1310
1511
|
session.chunks[chunkIndex] = chunk
|
|
1512
|
+
recordAsrCompleted(session, chunkIndex)
|
|
1311
1513
|
if (candidate && candidateRecordKey) {
|
|
1312
1514
|
if (session.providerCandidates?.[candidateRecordKey]) {
|
|
1313
1515
|
session.providerCandidates[candidateRecordKey].accepted = asrProvider === 'iphone-whisperkit-beta'
|
|
@@ -1322,7 +1524,7 @@ async function processStreamChunk(opts: {
|
|
|
1322
1524
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
1323
1525
|
|
|
1324
1526
|
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
|
|
1325
|
-
return
|
|
1527
|
+
return canonicalChunkResponse(chunk, sessionId, chunkIndex)
|
|
1326
1528
|
}
|
|
1327
1529
|
|
|
1328
1530
|
function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
|
|
@@ -1349,6 +1551,8 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
1349
1551
|
|
|
1350
1552
|
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
1351
1553
|
try {
|
|
1554
|
+
// Reject a wrong Mac before consuming or persisting any upload bytes.
|
|
1555
|
+
assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
|
|
1352
1556
|
const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
|
|
1353
1557
|
const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
1354
1558
|
const clientSpeaker = (req.query.speaker as string) || 'Unknown'
|