@gotcos/glasses-server 6.18.8 → 6.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/CHANGELOG.md +41 -0
- package/README.md +6 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/lib/maintenance-lifecycle.ts +8 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/meeting.ts +281 -2
- package/server/routes/transcribe-stream.ts +65 -13
package/.env.example
CHANGED
|
@@ -139,6 +139,17 @@ BIND_HOST=0.0.0.0
|
|
|
139
139
|
# keeps working if the default ever flips to Metal-on.
|
|
140
140
|
# COS_BATCH_HQ_FORCE_CPU=1
|
|
141
141
|
|
|
142
|
+
# ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
|
|
143
|
+
# Meeting audio whose save never landed is QUARANTINED, never deleted. It
|
|
144
|
+
# surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
|
|
145
|
+
# as an "Unsaved captures" row in the status card. One authenticated call
|
|
146
|
+
# recovers a capture into a durable meeting scribe:
|
|
147
|
+
# curl -X POST -H "x-cos-token: $COS_API_TOKEN" \
|
|
148
|
+
# http://127.0.0.1:3141/api/meeting/orphans/<sessionId>/recover
|
|
149
|
+
# Quarantined audio expires on this retention clock (hours, clamped 1-720).
|
|
150
|
+
# 72 covers a long weekend away from the Mac.
|
|
151
|
+
# COS_UNSAVED_AUDIO_RETENTION_HOURS=72
|
|
152
|
+
|
|
142
153
|
# ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
|
|
143
154
|
# Live meeting coaching cues on the lens: transcript window -> Composer
|
|
144
155
|
# planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
+
## 6.19.0
|
|
2
|
+
|
|
3
|
+
Meeting audio is evidence. This release stops the server from ever deleting an
|
|
4
|
+
unsaved capture, and makes batch status stop lying about finished work.
|
|
5
|
+
|
|
6
|
+
- **Unsaved-capture quarantine (the 2026-08-01 data-loss fix).** The
|
|
7
|
+
session-audio purge (boot sweep + 60s interval + non-saved session close)
|
|
8
|
+
DELETED any directory not tracked in memory once the 4h idle retention
|
|
9
|
+
passed — an offline meeting whose deferred save never landed lost its
|
|
10
|
+
full-fidelity audio within a minute. Two real meetings were destroyed this
|
|
11
|
+
way on 2026-08-01; only speaker-enrollment fragments survived. Audio-bearing
|
|
12
|
+
directories are now MOVED to `data/unsaved-audio/` with a manifest, never
|
|
13
|
+
deleted in place. Empty directories are still cleaned. A failed quarantine
|
|
14
|
+
move leaves the source untouched. Quarantine expires on
|
|
15
|
+
`COS_UNSAVED_AUDIO_RETENTION_HOURS` (default 72, clamped 1–720) — the only
|
|
16
|
+
place quarantined audio is ever deleted.
|
|
17
|
+
- **Unsaved captures are visible.** `/api/health` gains `unsaved_captures`
|
|
18
|
+
(count + compact items, same exposure level as `meeting_sync`). The
|
|
19
|
+
authenticated `GET /api/meeting/orphans` returns full detail.
|
|
20
|
+
- **Miles-triggered recovery, surface-only by decision (2026-08-02).**
|
|
21
|
+
`POST /api/meeting/orphans/:sessionId/recover` batch-transcribes the
|
|
22
|
+
quarantined WAVs (same segment/enhance/Metal-preempt contract as HQ polish,
|
|
23
|
+
under a new `orphan_recovery` maintenance lease), writes a durable scribe,
|
|
24
|
+
and hands off to operations when the COS pipeline is configured. Idempotent
|
|
25
|
+
via the save-receipt short-circuit; the server never drives recovery on its
|
|
26
|
+
own, and audio stays in quarantine until the retention clock — a failed
|
|
27
|
+
recovery is retryable.
|
|
28
|
+
- **Rejected HQ batches release their status.** A terminal batch outcome
|
|
29
|
+
(rejected quality, pipeline failure, accepted-but-unpersisted) now writes
|
|
30
|
+
`_batch_terminal.json` next to the retained WAVs. `meeting_sync` reports
|
|
31
|
+
those as `retained` — never as active work — so a rejected batch no longer
|
|
32
|
+
shows "HQ polish · N chunks" with `blocksRestart: true` for the 12h WAV
|
|
33
|
+
retention after the work already finished (observed on
|
|
34
|
+
meeting_1785695339502_mvqm0p, reason `repetitive-output`). A retry clears
|
|
35
|
+
the terminal record; live progress always wins. `meeting_sync.retained` is
|
|
36
|
+
additive — older consumers ignore it.
|
|
37
|
+
- Deferred by design: the realtime-model fallback port (W3) ships in its own
|
|
38
|
+
release. The app-side module has diverged ~1,100 lines from this repo's
|
|
39
|
+
whisper path; transplanting it alongside the data-loss fix would couple the
|
|
40
|
+
release's safest change to its riskiest. No default flips either way.
|
|
41
|
+
|
|
1
42
|
## 6.18.8
|
|
2
43
|
|
|
3
44
|
- **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
|
package/README.md
CHANGED
|
@@ -123,6 +123,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
123
123
|
locally through a network interruption. Reconnecting reconciles the exact
|
|
124
124
|
chunks already stored by the Mac, uploads only missing audio, and finalizes
|
|
125
125
|
through an idempotent save receipt without duplicating the meeting.
|
|
126
|
+
- Since 6.19.0, meeting audio whose save never lands is quarantined for 72 hours
|
|
127
|
+
(`COS_UNSAVED_AUDIO_RETENTION_HOURS`) instead of being cleaned up, surfaces on
|
|
128
|
+
`/api/health` as `unsaved_captures`, and can be recovered into a durable
|
|
129
|
+
meeting scribe with one authenticated call
|
|
130
|
+
(`POST /api/meeting/orphans/:sessionId/recover`; list via
|
|
131
|
+
`GET /api/meeting/orphans`).
|
|
126
132
|
- Local whisper.cpp transcription (free and local-only by default). OpenAI
|
|
127
133
|
Whisper fallback is optional and requires both the exact
|
|
128
134
|
`COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.19.0",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,6 +54,14 @@ export type MaintenanceWorkKind =
|
|
|
54
54
|
// budget must stay under COS Control's 90s drain timeout (main.swift:1853)
|
|
55
55
|
// or every drain that catches a cue in flight hard-fails to Repair.
|
|
56
56
|
| 'live_cue_pipeline'
|
|
57
|
+
// Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
|
|
58
|
+
// batch-transcribes retained WAVs into a durable scribe. Held for the whole
|
|
59
|
+
// background run so an Update Server drain waits for it like any batch.
|
|
60
|
+
// Long runs are made visible: the active-recovery registry renders a
|
|
61
|
+
// meeting_sync row with blocksRestart so COS Control warns BEFORE
|
|
62
|
+
// committing a drain into a decode that outlives its 90s timeout
|
|
63
|
+
// (main.swift:1963 waitForRestartProof).
|
|
64
|
+
| 'orphan_recovery'
|
|
57
65
|
|
|
58
66
|
export type MaintenanceWorkPhase = 'queued' | 'active'
|
|
59
67
|
export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { basename, join } from 'node:path'
|
|
7
7
|
import { dataPath } from './data-dir.js'
|
|
8
|
+
import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
|
|
8
9
|
|
|
9
10
|
export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
|
|
10
11
|
export const BATCH_PENDING_MARKER = '_batch_pending.marker'
|
|
12
|
+
export const BATCH_TERMINAL_FILENAME = '_batch_terminal.json'
|
|
11
13
|
|
|
12
14
|
export type MeetingBatchPhase =
|
|
13
15
|
| 'queued'
|
|
@@ -44,6 +46,75 @@ export interface MeetingSyncSnapshot {
|
|
|
44
46
|
label: string
|
|
45
47
|
blocksRestart: boolean
|
|
46
48
|
meetings: MeetingSyncMeeting[]
|
|
49
|
+
/** Batches that reached a terminal outcome but whose WAVs are deliberately
|
|
50
|
+
* retained for retry (rejected quality, failed persist). Additive field —
|
|
51
|
+
* older consumers ignore it. Never counts toward active/blocksRestart. */
|
|
52
|
+
retained: MeetingSyncRetainedMeeting[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type MeetingBatchOutcome = 'accepted' | 'rejected' | 'failed'
|
|
56
|
+
|
|
57
|
+
export interface MeetingBatchTerminal {
|
|
58
|
+
schemaVersion: 1
|
|
59
|
+
meetingId: string
|
|
60
|
+
outcome: MeetingBatchOutcome
|
|
61
|
+
reason?: string
|
|
62
|
+
at: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MeetingSyncRetainedMeeting {
|
|
66
|
+
meetingId: string
|
|
67
|
+
outcome: MeetingBatchOutcome
|
|
68
|
+
reason: string | null
|
|
69
|
+
chunkFiles: number
|
|
70
|
+
at: string
|
|
71
|
+
label: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Record the batch's terminal outcome next to its retained WAVs. Before this
|
|
75
|
+
* file existed (≤6.18.8), a rejected batch's dir kept rendering as active
|
|
76
|
+
* "HQ polish · N chunks" with blocksRestart:true for the full 12h retention —
|
|
77
|
+
* the status conflated "work running" with "evidence retained". */
|
|
78
|
+
export function writeMeetingBatchTerminal(
|
|
79
|
+
audioDir: string,
|
|
80
|
+
input: { outcome: MeetingBatchOutcome; reason?: string; meetingId?: string },
|
|
81
|
+
): void {
|
|
82
|
+
const payload: MeetingBatchTerminal = {
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
meetingId: input.meetingId ?? basename(audioDir),
|
|
85
|
+
outcome: input.outcome,
|
|
86
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
87
|
+
at: new Date().toISOString(),
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(join(audioDir, BATCH_TERMINAL_FILENAME), `${JSON.stringify(payload)}\n`, {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
mode: 0o600,
|
|
93
|
+
})
|
|
94
|
+
} catch {
|
|
95
|
+
// Status only — never fail the pipeline for a status write.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A retry invalidates the previous terminal state. */
|
|
100
|
+
export function clearMeetingBatchTerminal(audioDir: string): void {
|
|
101
|
+
const path = join(audioDir, BATCH_TERMINAL_FILENAME)
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(path)) unlinkSync(path)
|
|
104
|
+
} catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readTerminalFile(dir: string): MeetingBatchTerminal | null {
|
|
108
|
+
const path = join(dir, BATCH_TERMINAL_FILENAME)
|
|
109
|
+
if (!existsSync(path)) return null
|
|
110
|
+
try {
|
|
111
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchTerminal
|
|
112
|
+
if (raw?.schemaVersion !== 1) return null
|
|
113
|
+
if (raw.outcome !== 'accepted' && raw.outcome !== 'rejected' && raw.outcome !== 'failed') return null
|
|
114
|
+
return raw
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
47
118
|
}
|
|
48
119
|
|
|
49
120
|
function pendingBatchRoot(): string {
|
|
@@ -111,6 +182,11 @@ export function clearMeetingBatchProgress(audioDir: string): void {
|
|
|
111
182
|
} catch { /* ignore */ }
|
|
112
183
|
}
|
|
113
184
|
|
|
185
|
+
/** Public read for surfaces outside this module (orphan recovery progress). */
|
|
186
|
+
export function readMeetingBatchProgress(dir: string): MeetingBatchProgress | null {
|
|
187
|
+
return readProgressFile(dir)
|
|
188
|
+
}
|
|
189
|
+
|
|
114
190
|
function readProgressFile(dir: string): MeetingBatchProgress | null {
|
|
115
191
|
const path = join(dir, BATCH_PROGRESS_FILENAME)
|
|
116
192
|
if (!existsSync(path)) return null
|
|
@@ -140,8 +216,9 @@ export function getMeetingSyncSnapshot(
|
|
|
140
216
|
root: string = pendingBatchRoot(),
|
|
141
217
|
): MeetingSyncSnapshot {
|
|
142
218
|
const meetings: MeetingSyncMeeting[] = []
|
|
219
|
+
const retained: MeetingSyncRetainedMeeting[] = []
|
|
143
220
|
if (!existsSync(root)) {
|
|
144
|
-
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
|
|
221
|
+
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
|
|
145
222
|
}
|
|
146
223
|
|
|
147
224
|
let dirs: string[] = []
|
|
@@ -154,7 +231,7 @@ export function getMeetingSyncSnapshot(
|
|
|
154
231
|
}
|
|
155
232
|
})
|
|
156
233
|
} catch {
|
|
157
|
-
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
|
|
234
|
+
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
|
|
158
235
|
}
|
|
159
236
|
|
|
160
237
|
for (const name of dirs) {
|
|
@@ -165,6 +242,47 @@ export function getMeetingSyncSnapshot(
|
|
|
165
242
|
chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
|
|
166
243
|
} catch { /* ignore */ }
|
|
167
244
|
|
|
245
|
+
// A terminal outcome ends the meeting's ACTIVE life. Its WAVs stay for
|
|
246
|
+
// retry, reported as retained — never as running work. The gate is
|
|
247
|
+
// progress==null ONLY: the pending marker is refreshed every segment and
|
|
248
|
+
// every 60s during the run, so it is always fresh the moment a terminal
|
|
249
|
+
// is written — gating on marker freshness left the phantom alive for the
|
|
250
|
+
// first 15 minutes, exactly the post-meeting Update Server window. A
|
|
251
|
+
// genuine retry clears the terminal first (runMeetingBatchPipeline) and
|
|
252
|
+
// immediately writes queued progress, so progress presence is the true
|
|
253
|
+
// live signal.
|
|
254
|
+
const terminal = readTerminalFile(dir)
|
|
255
|
+
if (terminal && progress == null) {
|
|
256
|
+
const reasonSuffix = terminal.reason ? `: ${terminal.reason}` : ''
|
|
257
|
+
retained.push({
|
|
258
|
+
meetingId: terminal.meetingId || name,
|
|
259
|
+
outcome: terminal.outcome,
|
|
260
|
+
reason: terminal.reason ?? null,
|
|
261
|
+
chunkFiles,
|
|
262
|
+
at: terminal.at,
|
|
263
|
+
label: `Retained (${terminal.outcome}${reasonSuffix}) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
|
|
264
|
+
})
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Backfill: a dir with WAVs, no progress, no fresh marker, and NO terminal
|
|
269
|
+
// file is a batch that ended before 6.19.0 existed (or whose terminal
|
|
270
|
+
// write failed). Pre-6.19.0 semantics rendered these as phantom active
|
|
271
|
+
// work with blocksRestart for the rest of the 12h retention — and the
|
|
272
|
+
// first boot after an upgrade is exactly when the user watches COS
|
|
273
|
+
// Control. Classify them as retained with an honest unknown outcome.
|
|
274
|
+
if (progress == null && !markerFresh(dir) && chunkFiles > 0) {
|
|
275
|
+
retained.push({
|
|
276
|
+
meetingId: name,
|
|
277
|
+
outcome: 'failed',
|
|
278
|
+
reason: 'pre-terminal batch (ended before 6.19.0 or terminal write lost)',
|
|
279
|
+
chunkFiles,
|
|
280
|
+
at: new Date(0).toISOString(),
|
|
281
|
+
label: `Retained (unknown outcome) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
|
|
282
|
+
})
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
168
286
|
const active = markerFresh(dir) || progress != null
|
|
169
287
|
if (!active && chunkFiles === 0) continue
|
|
170
288
|
|
|
@@ -198,8 +316,36 @@ export function getMeetingSyncSnapshot(
|
|
|
198
316
|
meetings.push({ ...row, label: labelFor(row) })
|
|
199
317
|
}
|
|
200
318
|
|
|
319
|
+
// Active orphan recoveries decode in the quarantine root, which this scan
|
|
320
|
+
// never visits — surface them as active rows or COS Control shows "Idle"
|
|
321
|
+
// with blocksRestart:false while a 20-90 minute decode holds the
|
|
322
|
+
// maintenance lease, and an Update Server drain walks blind into its 90s
|
|
323
|
+
// timeout and hard-fails to Repair. Same contract as meeting_batch_finalization.
|
|
324
|
+
for (const recovery of listActiveRecoveries()) {
|
|
325
|
+
const progress = readProgressFile(recovery.dirPath)
|
|
326
|
+
const percent = progress && progress.segmentsTotal > 0
|
|
327
|
+
? clampPercent(progress.segmentsDone, progress.segmentsTotal)
|
|
328
|
+
: null
|
|
329
|
+
const row: Omit<MeetingSyncMeeting, 'label'> = {
|
|
330
|
+
meetingId: recovery.sessionId,
|
|
331
|
+
phase: progress?.phase ?? 'queued',
|
|
332
|
+
percent,
|
|
333
|
+
segmentsDone: progress && progress.segmentsTotal > 0 ? progress.segmentsDone : null,
|
|
334
|
+
segmentsTotal: progress && progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
|
|
335
|
+
chunkFiles: progress?.chunkFiles ?? 0,
|
|
336
|
+
updatedAt: progress?.updatedAt ?? null,
|
|
337
|
+
}
|
|
338
|
+
meetings.push({
|
|
339
|
+
...row,
|
|
340
|
+
label: `Recovering unsaved capture${percent != null ? ` ${percent}%` : ''} · do not update/restart`,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
201
344
|
if (meetings.length === 0) {
|
|
202
|
-
|
|
345
|
+
const label = retained.length > 0
|
|
346
|
+
? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
|
|
347
|
+
: 'Idle'
|
|
348
|
+
return { active: false, percent: null, label, blocksRestart: false, meetings, retained }
|
|
203
349
|
}
|
|
204
350
|
|
|
205
351
|
const withPercent = meetings.filter(m => m.percent != null)
|
|
@@ -217,5 +363,6 @@ export function getMeetingSyncSnapshot(
|
|
|
217
363
|
label,
|
|
218
364
|
blocksRestart: true,
|
|
219
365
|
meetings,
|
|
366
|
+
retained,
|
|
220
367
|
}
|
|
221
368
|
}
|
|
@@ -9,7 +9,9 @@ import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
|
|
|
9
9
|
import { isMetalBatchPreempted } from './whisper-metal-gate.js'
|
|
10
10
|
import {
|
|
11
11
|
clearMeetingBatchProgress,
|
|
12
|
+
clearMeetingBatchTerminal,
|
|
12
13
|
writeMeetingBatchProgress,
|
|
14
|
+
writeMeetingBatchTerminal,
|
|
13
15
|
} from './meeting-batch-progress.js'
|
|
14
16
|
import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
|
|
15
17
|
import {
|
|
@@ -165,7 +167,7 @@ function mapWordsToSpeakers(
|
|
|
165
167
|
})
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
async function transcribeSegments(
|
|
170
|
+
export async function transcribeSegments(
|
|
169
171
|
audioDir: string,
|
|
170
172
|
segments: BatchSegment[],
|
|
171
173
|
entries: IndexedTranscriptChunk[],
|
|
@@ -237,6 +239,16 @@ async function transcribeSegments(
|
|
|
237
239
|
|
|
238
240
|
let batchQueueTail: Promise<void> = Promise.resolve()
|
|
239
241
|
|
|
242
|
+
/** Chain arbitrary HQ-decoder work onto the same serialization tail the batch
|
|
243
|
+
* pipeline uses. Orphan recovery MUST go through this: transcribeSegments has
|
|
244
|
+
* no internal queue, so calling it directly would run a second (or third)
|
|
245
|
+
* 16-thread large-v3 decoder in parallel with a live post-meeting batch. */
|
|
246
|
+
export function enqueueSerializedHqWork<T>(work: () => Promise<T>): Promise<T> {
|
|
247
|
+
const job = batchQueueTail.then(work)
|
|
248
|
+
batchQueueTail = job.then(() => undefined, () => undefined)
|
|
249
|
+
return job
|
|
250
|
+
}
|
|
251
|
+
|
|
240
252
|
/** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
|
|
241
253
|
export function runMeetingBatchPipeline(
|
|
242
254
|
audioDir: string,
|
|
@@ -246,6 +258,8 @@ export function runMeetingBatchPipeline(
|
|
|
246
258
|
// Lease immediately, including time spent behind another HQ decoder. Without
|
|
247
259
|
// this, the two-hour cleanup could delete a queued meeting before it starts.
|
|
248
260
|
refreshPendingLease(audioDir)
|
|
261
|
+
// A retry invalidates any prior terminal outcome — live signals must win.
|
|
262
|
+
clearMeetingBatchTerminal(audioDir)
|
|
249
263
|
writeMeetingBatchProgress(audioDir, {
|
|
250
264
|
phase: 'queued',
|
|
251
265
|
segmentsDone: 0,
|
|
@@ -278,7 +292,12 @@ async function runMeetingBatchPipelineNow(
|
|
|
278
292
|
return { transcriptionQuality: 'streaming' }
|
|
279
293
|
}
|
|
280
294
|
const segments = segmentTranscriptChunks(entries)
|
|
281
|
-
if (segments.length === 0)
|
|
295
|
+
if (segments.length === 0) {
|
|
296
|
+
// Terminal too: WAVs exist but nothing is transcribable. Without this,
|
|
297
|
+
// the dir re-creates the exact phantom-active state W2 removes.
|
|
298
|
+
writeMeetingBatchTerminal(audioDir, { outcome: 'failed', reason: 'no_segments' })
|
|
299
|
+
return { transcriptionQuality: 'streaming' }
|
|
300
|
+
}
|
|
282
301
|
|
|
283
302
|
writeMeetingBatchProgress(audioDir, {
|
|
284
303
|
phase: 'hq_polish',
|
|
@@ -302,12 +321,20 @@ async function runMeetingBatchPipelineNow(
|
|
|
302
321
|
+ `${qualityReport.streamingWordCount} live words, `
|
|
303
322
|
+ `${(qualityReport.duplicateWordRatio * 100).toFixed(1)}% duplicate`,
|
|
304
323
|
)
|
|
324
|
+
// Terminal: the batch RAN and lost. WAVs stay for retry, but status must
|
|
325
|
+
// stop reporting active work (pre-6.19.0 this looked like 12h of
|
|
326
|
+
// "HQ polish · N chunks" with blocksRestart:true after the work ended).
|
|
327
|
+
writeMeetingBatchTerminal(audioDir, { outcome: 'rejected', reason: qualityReport.reason })
|
|
305
328
|
return { transcriptionQuality: 'streaming', qualityReport }
|
|
306
329
|
}
|
|
307
330
|
|
|
308
331
|
return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
|
|
309
332
|
} catch (error) {
|
|
310
333
|
console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
334
|
+
writeMeetingBatchTerminal(audioDir, {
|
|
335
|
+
outcome: 'failed',
|
|
336
|
+
reason: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
|
|
337
|
+
})
|
|
311
338
|
return { transcriptionQuality: 'streaming' }
|
|
312
339
|
}
|
|
313
340
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Quarantine for unsaved meeting audio.
|
|
2
|
+
//
|
|
3
|
+
// Until 6.19.0 the session-audio purge (boot sweep + 60s interval) DELETED any
|
|
4
|
+
// session-audio directory that was not in the in-memory sessions map and did
|
|
5
|
+
// not carry a fresh save-preserved marker. An offline meeting whose deferred
|
|
6
|
+
// save never landed therefore lost its full-fidelity audio within a minute of
|
|
7
|
+
// the server no longer tracking the session — two real meetings were destroyed
|
|
8
|
+
// this way on 2026-08-01. The only surviving fragments were in ext-audio,
|
|
9
|
+
// which is a speaker-enrollment store (unrecognized-speaker chunks, hard
|
|
10
|
+
// capped), not meeting audio.
|
|
11
|
+
//
|
|
12
|
+
// The rule now: audio evidence is moved here, never deleted in place. A
|
|
13
|
+
// quarantined capture is surfaced on /api/health (unsaved_captures) and can be
|
|
14
|
+
// driven to a durable scribe via POST /api/meeting/orphans/:sessionId/recover.
|
|
15
|
+
// Quarantine expires on a retention clock (default 72h) — long enough to
|
|
16
|
+
// survive a weekend away from the Mac, bounded enough not to grow forever.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
statSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { basename, join, resolve } from 'node:path'
|
|
29
|
+
import { dataPath } from './data-dir.js'
|
|
30
|
+
|
|
31
|
+
export const QUARANTINE_MANIFEST = '_quarantine.json'
|
|
32
|
+
export const RECOVERED_RECEIPT = '_recovered.json'
|
|
33
|
+
const CHUNK_WAV_RE = /^chunk_\d{4}\.wav$/
|
|
34
|
+
|
|
35
|
+
const DEFAULT_RETENTION_HOURS = 72
|
|
36
|
+
const MIN_RETENTION_HOURS = 1
|
|
37
|
+
const MAX_RETENTION_HOURS = 720
|
|
38
|
+
|
|
39
|
+
export interface QuarantineManifest {
|
|
40
|
+
schemaVersion: 1
|
|
41
|
+
sessionId: string
|
|
42
|
+
quarantinedAt: string
|
|
43
|
+
reason: string
|
|
44
|
+
chunkFiles: number
|
|
45
|
+
bytes: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UnsavedCapture {
|
|
49
|
+
sessionId: string
|
|
50
|
+
dirName: string
|
|
51
|
+
quarantinedAt: string | null
|
|
52
|
+
ageHours: number | null
|
|
53
|
+
chunkFiles: number
|
|
54
|
+
bytes: number
|
|
55
|
+
reason: string | null
|
|
56
|
+
expiresAt: string | null
|
|
57
|
+
recovered: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function unsavedAudioRoot(): string {
|
|
61
|
+
return dataPath('unsaved-audio')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function unsavedAudioRetentionMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
65
|
+
// An EMPTY value means unset, not zero: Number('') === 0, which would clamp
|
|
66
|
+
// to the 1h minimum and silently collapse the 72h safety net — on the one
|
|
67
|
+
// knob whose failure mode is losing the audio this module exists to keep.
|
|
68
|
+
const rawText = (env.COS_UNSAVED_AUDIO_RETENTION_HOURS ?? '').trim()
|
|
69
|
+
if (rawText === '') return DEFAULT_RETENTION_HOURS * 60 * 60 * 1000
|
|
70
|
+
const raw = Number(rawText)
|
|
71
|
+
const hours = Number.isFinite(raw)
|
|
72
|
+
? Math.min(MAX_RETENTION_HOURS, Math.max(MIN_RETENTION_HOURS, raw))
|
|
73
|
+
: DEFAULT_RETENTION_HOURS
|
|
74
|
+
return hours * 60 * 60 * 1000
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function countChunkWavs(dirPath: string): number {
|
|
78
|
+
try {
|
|
79
|
+
return readdirSync(dirPath).filter(name => CHUNK_WAV_RE.test(name)).length
|
|
80
|
+
} catch {
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function dirBytes(dirPath: string): number {
|
|
86
|
+
let total = 0
|
|
87
|
+
try {
|
|
88
|
+
for (const name of readdirSync(dirPath)) {
|
|
89
|
+
try { total += statSync(join(dirPath, name)).size } catch { /* skip */ }
|
|
90
|
+
}
|
|
91
|
+
} catch { /* empty */ }
|
|
92
|
+
return total
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Move one session-audio dir into quarantine instead of deleting it.
|
|
96
|
+
* Returns the quarantine path, or null when the move could not be made
|
|
97
|
+
* (in which case the SOURCE IS LEFT IN PLACE — never deleted on failure). */
|
|
98
|
+
export function quarantineSessionAudio(
|
|
99
|
+
sourceDir: string,
|
|
100
|
+
reason: string,
|
|
101
|
+
root: string = unsavedAudioRoot(),
|
|
102
|
+
): string | null {
|
|
103
|
+
const sessionId = basename(sourceDir)
|
|
104
|
+
try {
|
|
105
|
+
if (!existsSync(sourceDir)) return null
|
|
106
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
107
|
+
let target = resolve(root, sessionId)
|
|
108
|
+
if (existsSync(target)) target = resolve(root, `${sessionId}.${Date.now()}`)
|
|
109
|
+
renameSync(sourceDir, target)
|
|
110
|
+
const manifest: QuarantineManifest = {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
sessionId,
|
|
113
|
+
quarantinedAt: new Date().toISOString(),
|
|
114
|
+
reason,
|
|
115
|
+
chunkFiles: countChunkWavs(target),
|
|
116
|
+
bytes: dirBytes(target),
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
})
|
|
123
|
+
} catch { /* manifest is observability; the audio move already succeeded */ }
|
|
124
|
+
return target
|
|
125
|
+
} catch {
|
|
126
|
+
// Rename failed (cross-device, permissions, race). Leave the source alone —
|
|
127
|
+
// a skipped purge is recoverable, a deleted capture is not.
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type OrphanSweepAction =
|
|
133
|
+
| { dir: string; action: 'kept_live' }
|
|
134
|
+
| { dir: string; action: 'kept_preserved' }
|
|
135
|
+
| { dir: string; action: 'quarantined'; target: string }
|
|
136
|
+
| { dir: string; action: 'quarantine_failed' }
|
|
137
|
+
| { dir: string; action: 'deleted_empty' }
|
|
138
|
+
|
|
139
|
+
/** Shared sweep for the boot path and the 60s interval. A directory that
|
|
140
|
+
* still holds chunk audio is quarantined; only chunk-less directories are
|
|
141
|
+
* deleted. A failed quarantine move keeps the source in place. */
|
|
142
|
+
export function sweepOrphanedSessionAudio(
|
|
143
|
+
sessionAudioRoot: string,
|
|
144
|
+
opts: {
|
|
145
|
+
isLive: (sessionId: string) => boolean
|
|
146
|
+
hasFreshPreservedMarker: (dirPath: string) => boolean
|
|
147
|
+
reason: string
|
|
148
|
+
quarantineRoot?: string
|
|
149
|
+
},
|
|
150
|
+
): OrphanSweepAction[] {
|
|
151
|
+
const actions: OrphanSweepAction[] = []
|
|
152
|
+
let entries: string[] = []
|
|
153
|
+
try {
|
|
154
|
+
entries = readdirSync(sessionAudioRoot)
|
|
155
|
+
} catch {
|
|
156
|
+
return actions
|
|
157
|
+
}
|
|
158
|
+
for (const dir of entries) {
|
|
159
|
+
const dirPath = resolve(sessionAudioRoot, dir)
|
|
160
|
+
try {
|
|
161
|
+
if (!statSync(dirPath).isDirectory()) continue
|
|
162
|
+
} catch { continue }
|
|
163
|
+
if (opts.isLive(dir)) { actions.push({ dir, action: 'kept_live' }); continue }
|
|
164
|
+
if (opts.hasFreshPreservedMarker(dirPath)) { actions.push({ dir, action: 'kept_preserved' }); continue }
|
|
165
|
+
if (countChunkWavs(dirPath) > 0) {
|
|
166
|
+
const target = quarantineSessionAudio(dirPath, opts.reason, opts.quarantineRoot)
|
|
167
|
+
actions.push(target
|
|
168
|
+
? { dir, action: 'quarantined', target }
|
|
169
|
+
: { dir, action: 'quarantine_failed' })
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
rmSync(dirPath, { recursive: true, force: true })
|
|
174
|
+
actions.push({ dir, action: 'deleted_empty' })
|
|
175
|
+
} catch { /* next sweep retries */ }
|
|
176
|
+
}
|
|
177
|
+
return actions
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readManifest(dirPath: string): QuarantineManifest | null {
|
|
181
|
+
try {
|
|
182
|
+
const raw = JSON.parse(readFileSync(resolve(dirPath, QUARANTINE_MANIFEST), 'utf8')) as QuarantineManifest
|
|
183
|
+
if (raw?.schemaVersion !== 1 || typeof raw.sessionId !== 'string') return null
|
|
184
|
+
return raw
|
|
185
|
+
} catch {
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function quarantinedAtMs(dirPath: string, manifest: QuarantineManifest | null): number | null {
|
|
191
|
+
if (manifest) {
|
|
192
|
+
const parsed = Date.parse(manifest.quarantinedAt)
|
|
193
|
+
if (Number.isFinite(parsed)) return parsed
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
return statSync(dirPath).mtimeMs
|
|
197
|
+
} catch {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Everything currently in quarantine, newest first. Recovered captures are
|
|
203
|
+
* flagged (they linger until the retention clock clears them) so health can
|
|
204
|
+
* exclude them from the actionable count. */
|
|
205
|
+
export function listUnsavedCaptures(
|
|
206
|
+
root: string = unsavedAudioRoot(),
|
|
207
|
+
retentionMs: number = unsavedAudioRetentionMs(),
|
|
208
|
+
): UnsavedCapture[] {
|
|
209
|
+
const captures: UnsavedCapture[] = []
|
|
210
|
+
let entries: string[] = []
|
|
211
|
+
try {
|
|
212
|
+
entries = readdirSync(root)
|
|
213
|
+
} catch {
|
|
214
|
+
return captures
|
|
215
|
+
}
|
|
216
|
+
for (const dir of entries) {
|
|
217
|
+
const dirPath = resolve(root, dir)
|
|
218
|
+
try {
|
|
219
|
+
if (!statSync(dirPath).isDirectory()) continue
|
|
220
|
+
} catch { continue }
|
|
221
|
+
const manifest = readManifest(dirPath)
|
|
222
|
+
const atMs = quarantinedAtMs(dirPath, manifest)
|
|
223
|
+
captures.push({
|
|
224
|
+
sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
|
|
225
|
+
dirName: dir,
|
|
226
|
+
quarantinedAt: atMs != null ? new Date(atMs).toISOString() : null,
|
|
227
|
+
ageHours: atMs != null ? Math.round(((Date.now() - atMs) / 3_600_000) * 10) / 10 : null,
|
|
228
|
+
chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
|
|
229
|
+
bytes: manifest?.bytes ?? dirBytes(dirPath),
|
|
230
|
+
reason: manifest?.reason ?? null,
|
|
231
|
+
expiresAt: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
|
|
232
|
+
recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
captures.sort((a, b) => (b.quarantinedAt ?? '').localeCompare(a.quarantinedAt ?? ''))
|
|
236
|
+
return captures
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Retention: quarantined captures past the clock are removed. This is the
|
|
240
|
+
* ONLY place quarantined audio is ever deleted. */
|
|
241
|
+
export function purgeExpiredQuarantine(
|
|
242
|
+
root: string = unsavedAudioRoot(),
|
|
243
|
+
retentionMs: number = unsavedAudioRetentionMs(),
|
|
244
|
+
): string[] {
|
|
245
|
+
const purged: string[] = []
|
|
246
|
+
for (const capture of listUnsavedCaptures(root, retentionMs)) {
|
|
247
|
+
// Never delete a directory a recovery is actively decoding — a capture
|
|
248
|
+
// sitting exactly at the retention boundary would otherwise lose its
|
|
249
|
+
// audio mid-run, in the module whose thesis is "never delete evidence".
|
|
250
|
+
if (isRecoveryActive(capture.dirName)) continue
|
|
251
|
+
const atMs = capture.quarantinedAt ? Date.parse(capture.quarantinedAt) : NaN
|
|
252
|
+
if (!Number.isFinite(atMs)) continue
|
|
253
|
+
if (Date.now() - atMs > retentionMs) {
|
|
254
|
+
try {
|
|
255
|
+
rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
|
|
256
|
+
purged.push(capture.dirName)
|
|
257
|
+
} catch { /* next sweep retries */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return purged
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Resolve the quarantine dir for a sessionId (exact dir or timestamp-suffixed). */
|
|
264
|
+
export function findQuarantineDir(
|
|
265
|
+
sessionId: string,
|
|
266
|
+
root: string = unsavedAudioRoot(),
|
|
267
|
+
): string | null {
|
|
268
|
+
const exact = resolve(root, sessionId)
|
|
269
|
+
if (existsSync(exact)) return exact
|
|
270
|
+
try {
|
|
271
|
+
for (const dir of readdirSync(root)) {
|
|
272
|
+
if (dir === sessionId || dir.startsWith(`${sessionId}.`)) {
|
|
273
|
+
const dirPath = resolve(root, dir)
|
|
274
|
+
if (statSync(dirPath).isDirectory()) return dirPath
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch { /* fall through */ }
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Active recovery registry ─────────────────────────────────────────────
|
|
282
|
+
// In-memory, single-process (matches the sessions map's own scope). Two
|
|
283
|
+
// consumers: purgeExpiredQuarantine skips dirs being recovered (a capture at
|
|
284
|
+
// the retention boundary must not be deleted mid-decode), and meeting_sync
|
|
285
|
+
// renders an active row so COS Control warns BEFORE committing an Update
|
|
286
|
+
// Server drain into a 20-90 minute recovery it cannot see.
|
|
287
|
+
|
|
288
|
+
const activeRecoveries = new Map<string, { sessionId: string; dirPath: string; startedAt: number }>()
|
|
289
|
+
|
|
290
|
+
export function registerActiveRecovery(sessionId: string, dirPath: string): void {
|
|
291
|
+
activeRecoveries.set(basename(dirPath), { sessionId, dirPath, startedAt: Date.now() })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function clearActiveRecovery(dirPath: string): void {
|
|
295
|
+
activeRecoveries.delete(basename(dirPath))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function listActiveRecoveries(): Array<{ sessionId: string; dirPath: string; startedAt: number }> {
|
|
299
|
+
return [...activeRecoveries.values()]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function isRecoveryActive(dirName: string): boolean {
|
|
303
|
+
return activeRecoveries.has(dirName)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function markRecovered(dirPath: string, savedFilename: string): void {
|
|
307
|
+
try {
|
|
308
|
+
writeFileSync(
|
|
309
|
+
resolve(dirPath, RECOVERED_RECEIPT),
|
|
310
|
+
`${JSON.stringify({ schemaVersion: 1, recoveredAt: new Date().toISOString(), savedFilename })}\n`,
|
|
311
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
312
|
+
)
|
|
313
|
+
} catch { /* receipt is best-effort; findBySessionId remains the true guard */ }
|
|
314
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
|
|
|
38
38
|
import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
|
|
39
39
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
40
40
|
import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
|
|
41
|
+
import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
|
|
41
42
|
|
|
42
43
|
export const healthRouter = Router()
|
|
43
44
|
|
|
@@ -261,6 +262,20 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
261
262
|
const cursorSnapshot = getCursorModelCatalogSnapshot()
|
|
262
263
|
const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
|
|
263
264
|
const meeting_sync = getMeetingSyncSnapshot()
|
|
265
|
+
// Quarantined unsaved captures (6.19.0). Compact on this unauthenticated
|
|
266
|
+
// surface — same exposure level as meeting_sync's meetingIds. Full detail
|
|
267
|
+
// plus the recover action live on the authenticated /api/meeting/orphans.
|
|
268
|
+
const unsavedList = listUnsavedCaptures()
|
|
269
|
+
const unsaved_captures = {
|
|
270
|
+
count: unsavedList.filter(item => !item.recovered).length,
|
|
271
|
+
items: unsavedList.slice(0, 10).map(item => ({
|
|
272
|
+
sessionId: item.sessionId,
|
|
273
|
+
ageHours: item.ageHours,
|
|
274
|
+
chunkFiles: item.chunkFiles,
|
|
275
|
+
expiresAt: item.expiresAt,
|
|
276
|
+
recovered: item.recovered,
|
|
277
|
+
})),
|
|
278
|
+
}
|
|
264
279
|
res.json({
|
|
265
280
|
...checks,
|
|
266
281
|
server_version: managedServerVersion(),
|
|
@@ -276,6 +291,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
276
291
|
codex_models,
|
|
277
292
|
cursor_models,
|
|
278
293
|
meeting_sync,
|
|
294
|
+
unsaved_captures,
|
|
279
295
|
capabilities: {
|
|
280
296
|
transcription: { ...transcription, hq: transcriptionHq },
|
|
281
297
|
recovery,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
3
3
|
// are durable before the session is closed; batch improvement runs afterward.
|
|
4
4
|
|
|
5
|
-
import { rmSync } from 'node:fs'
|
|
5
|
+
import { readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
6
7
|
import { Router } from 'express'
|
|
7
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
8
9
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
@@ -17,7 +18,29 @@ import {
|
|
|
17
18
|
persistBatchDecisionSidecar,
|
|
18
19
|
replaceMeetingTranscriptAtomic,
|
|
19
20
|
} from '../lib/meeting-batch-persistence.js'
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
BATCH_PENDING_MARKER,
|
|
23
|
+
clearMeetingBatchProgress,
|
|
24
|
+
readMeetingBatchProgress,
|
|
25
|
+
writeMeetingBatchTerminal,
|
|
26
|
+
} from '../lib/meeting-batch-progress.js'
|
|
27
|
+
import {
|
|
28
|
+
enqueueSerializedHqWork,
|
|
29
|
+
runMeetingBatchPipeline,
|
|
30
|
+
segmentTranscriptChunks,
|
|
31
|
+
transcribeSegments,
|
|
32
|
+
} from '../lib/meeting-batch-transcribe.js'
|
|
33
|
+
import {
|
|
34
|
+
clearActiveRecovery,
|
|
35
|
+
findQuarantineDir,
|
|
36
|
+
listUnsavedCaptures,
|
|
37
|
+
markRecovered,
|
|
38
|
+
registerActiveRecovery,
|
|
39
|
+
} from '../lib/unsaved-audio-quarantine.js'
|
|
40
|
+
import {
|
|
41
|
+
clearSessionHallucinationState,
|
|
42
|
+
stripInlineHallucinations,
|
|
43
|
+
} from '../lib/hallucination-filter.js'
|
|
21
44
|
import {
|
|
22
45
|
selectBatchTranscriptForPersistence,
|
|
23
46
|
type BatchTranscription,
|
|
@@ -376,9 +399,255 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
376
399
|
}
|
|
377
400
|
})
|
|
378
401
|
|
|
402
|
+
// ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
|
|
403
|
+
// Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
|
|
404
|
+
// recovery on its own. It lists what the quarantine holds, and one
|
|
405
|
+
// authenticated POST drives one capture to a durable scribe.
|
|
406
|
+
const recoveringOrphans = new Set<string>()
|
|
407
|
+
|
|
408
|
+
router.get('/meeting/orphans', (_req, res) => {
|
|
409
|
+
res.set('Cache-Control', 'private, no-store')
|
|
410
|
+
const items = listUnsavedCaptures()
|
|
411
|
+
// In-flight recovery progress: transcribeSegments writes its progress file
|
|
412
|
+
// into the quarantine dir, invisible to meeting_sync (different root) —
|
|
413
|
+
// surface it here so a long recovery is not a black box.
|
|
414
|
+
const recoveringProgress: Record<string, { segmentsDone: number; segmentsTotal: number } | null> = {}
|
|
415
|
+
for (const id of recoveringOrphans) {
|
|
416
|
+
const dir = findQuarantineDir(id)
|
|
417
|
+
const progress = dir ? readMeetingBatchProgress(dir) : null
|
|
418
|
+
recoveringProgress[id] = progress
|
|
419
|
+
? { segmentsDone: progress.segmentsDone, segmentsTotal: progress.segmentsTotal }
|
|
420
|
+
: null
|
|
421
|
+
}
|
|
422
|
+
res.json({
|
|
423
|
+
count: items.filter(item => !item.recovered).length,
|
|
424
|
+
recovering: [...recoveringOrphans],
|
|
425
|
+
recoveringProgress,
|
|
426
|
+
items,
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
router.post('/meeting/orphans/:sessionId/recover', (req, res) => {
|
|
431
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
432
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
433
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const body = (req.body ?? {}) as Record<string, unknown>
|
|
437
|
+
if (body.title !== undefined && typeof body.title !== 'string') {
|
|
438
|
+
res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (body.domain !== undefined && typeof body.domain !== 'string') {
|
|
442
|
+
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
// Idempotent: a capture that already reached a durable save replays its
|
|
446
|
+
// receipt — same contract as POST /meeting/save.
|
|
447
|
+
const alreadySaved = store.findBySessionId(sessionId)
|
|
448
|
+
if (alreadySaved) {
|
|
449
|
+
// Stamp the quarantine receipt too, or a saved-but-quarantined capture
|
|
450
|
+
// (e.g. save succeeded but the pending handoff failed, marker expired,
|
|
451
|
+
// sweep quarantined the leftovers — the meeting IS saved) counts as
|
|
452
|
+
// "unsaved" on health forever, a false alarm that trains the user to
|
|
453
|
+
// ignore the one channel built to report real losses.
|
|
454
|
+
const staleQuarantine = findQuarantineDir(sessionId)
|
|
455
|
+
if (staleQuarantine) markRecovered(staleQuarantine, alreadySaved.filename)
|
|
456
|
+
res.set('Cache-Control', 'private, no-store')
|
|
457
|
+
res.json({ accepted: false, alreadySaved: true, receipt: publicSaveResponse(alreadySaved, true) })
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
const quarantineDir = findQuarantineDir(sessionId)
|
|
461
|
+
if (!quarantineDir) {
|
|
462
|
+
res.status(404).json({ error: 'No quarantined audio for this session', reason: 'orphan_not_found' })
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
if (recoveringOrphans.has(sessionId)) {
|
|
466
|
+
res.status(409).json({ error: 'Recovery already in progress', reason: 'recovery_in_progress' })
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
|
|
470
|
+
const entries = capture.entries
|
|
471
|
+
if (entries.length === 0) {
|
|
472
|
+
res.status(422).json({ error: 'Quarantined directory holds no readable chunk audio', reason: 'no_chunk_audio' })
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Acquire BEFORE marking the session as recovering: if a drain gate makes
|
|
477
|
+
// acquire throw, nothing must linger in recoveringOrphans — a leaked entry
|
|
478
|
+
// would 409 every retry for the exact capture this route exists to save.
|
|
479
|
+
let lease: MaintenanceWorkLease
|
|
480
|
+
try {
|
|
481
|
+
lease = acquireMaintenanceWork('orphan_recovery', { phase: 'queued' })
|
|
482
|
+
} catch {
|
|
483
|
+
res.status(503).json({
|
|
484
|
+
error: 'Server is draining for maintenance — retry after the update completes',
|
|
485
|
+
reason: 'maintenance_drain',
|
|
486
|
+
})
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
recoveringOrphans.add(sessionId)
|
|
490
|
+
// Registry drives two protections: meeting_sync renders this as an active
|
|
491
|
+
// row (COS Control warns before an Update Server drain walks into a
|
|
492
|
+
// 20-90 min decode), and purgeExpiredQuarantine will not delete the dir
|
|
493
|
+
// mid-run even at the retention boundary.
|
|
494
|
+
registerActiveRecovery(sessionId, quarantineDir)
|
|
495
|
+
res.status(202).set('Cache-Control', 'private, no-store').json({
|
|
496
|
+
accepted: true,
|
|
497
|
+
sessionId,
|
|
498
|
+
chunkFiles: entries.length,
|
|
499
|
+
note: 'Recovery runs in the background behind the HQ decoder queue — expect minutes for a long meeting '
|
|
500
|
+
+ '(watch recoveringProgress on GET /api/meeting/orphans). Speakers are labeled Unknown: no live ASR ever '
|
|
501
|
+
+ 'ran for this capture. The capture leaves the unsaved count once its scribe is durable.',
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const task = Promise.resolve().then(async () => {
|
|
505
|
+
lease.setPhase('active')
|
|
506
|
+
const startedAt = Date.now()
|
|
507
|
+
const segments = segmentTranscriptChunks(entries)
|
|
508
|
+
// transcribeSegments carries the batch contract (enhancement, Metal
|
|
509
|
+
// preemption discard + one CPU retry, overlap stripping) but NOT the
|
|
510
|
+
// decoder serialization — that lives in the queue tail. Chain onto it,
|
|
511
|
+
// or two back-to-back recoveries plus a live post-meeting batch would
|
|
512
|
+
// run parallel 16-thread large-v3 decoders on the user's Mac.
|
|
513
|
+
const results = await enqueueSerializedHqWork(
|
|
514
|
+
() => transcribeSegments(quarantineDir, segments, entries),
|
|
515
|
+
)
|
|
516
|
+
// Batch whisper over a dead session has no streaming baseline to
|
|
517
|
+
// compare against (evaluateBatchQuality needs one), but the inline
|
|
518
|
+
// hallucination filter needs none — run the same two-pass the boot
|
|
519
|
+
// session-recovery path uses: pass 1 builds the frequency blocklist
|
|
520
|
+
// across all segments, pass 2 strips with the final blocklist.
|
|
521
|
+
for (const item of results) {
|
|
522
|
+
if (item.text) stripInlineHallucinations(item.text, sessionId)
|
|
523
|
+
}
|
|
524
|
+
for (const item of results) {
|
|
525
|
+
if (item.text) item.text = stripInlineHallucinations(item.text, sessionId)
|
|
526
|
+
}
|
|
527
|
+
clearSessionHallucinationState(sessionId)
|
|
528
|
+
const transcript = cleanFinalTranscript(results.map(item => item.text).join(' '))
|
|
529
|
+
if (!transcript.trim()) {
|
|
530
|
+
throw new Error('recovery produced an empty transcript')
|
|
531
|
+
}
|
|
532
|
+
const recoveredChunks = results.map(item => ({
|
|
533
|
+
text: item.text,
|
|
534
|
+
speaker: 'Unknown',
|
|
535
|
+
elapsed: item.segment.startElapsed,
|
|
536
|
+
similarity: 0,
|
|
537
|
+
words: item.words,
|
|
538
|
+
canonical: true,
|
|
539
|
+
}))
|
|
540
|
+
const saved = store.save({
|
|
541
|
+
sessionId,
|
|
542
|
+
title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
|
|
543
|
+
domain: typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
|
|
544
|
+
transcript,
|
|
545
|
+
startTime: capture.startTime,
|
|
546
|
+
durationMs: capture.durationMs,
|
|
547
|
+
chunks: recoveredChunks,
|
|
548
|
+
chunkEntries: recoveredChunks.map((chunk, position) => ({
|
|
549
|
+
chunkIndex: results[position]?.segment.startChunkIdx ?? position,
|
|
550
|
+
chunk,
|
|
551
|
+
})),
|
|
552
|
+
transferIntegrity: null,
|
|
553
|
+
})
|
|
554
|
+
markRecovered(quarantineDir, saved.filename)
|
|
555
|
+
console.log(
|
|
556
|
+
`[meeting/orphans] Recovered ${sessionId} → ${saved.filename} `
|
|
557
|
+
+ `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s)`,
|
|
558
|
+
)
|
|
559
|
+
try {
|
|
560
|
+
emit({
|
|
561
|
+
type: 'recording_stop',
|
|
562
|
+
data: {
|
|
563
|
+
sessionId,
|
|
564
|
+
filename: saved.filename,
|
|
565
|
+
durationMin: saved.durationMin,
|
|
566
|
+
domain: saved.domain,
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
} catch { /* display is best-effort */ }
|
|
570
|
+
if (cosOpsPipelineConfigured()) {
|
|
571
|
+
await handoffMeetingToOperations(saved.filepath)
|
|
572
|
+
}
|
|
573
|
+
}).catch(error => {
|
|
574
|
+
// The quarantined audio is untouched on failure — retry stays possible
|
|
575
|
+
// until the retention clock clears it.
|
|
576
|
+
console.error(
|
|
577
|
+
`[meeting/orphans] Recovery failed for ${sessionId}: `
|
|
578
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
579
|
+
)
|
|
580
|
+
}).finally(() => {
|
|
581
|
+
// transcribeSegments wrote progress + a pending marker into the
|
|
582
|
+
// quarantine dir; the batch pipeline's own finally never runs here, so
|
|
583
|
+
// clean them up or the dir carries stale work files until retention.
|
|
584
|
+
try { clearMeetingBatchProgress(quarantineDir) } catch { /* best-effort */ }
|
|
585
|
+
try { unlinkSync(resolve(quarantineDir, BATCH_PENDING_MARKER)) } catch { /* best-effort */ }
|
|
586
|
+
clearActiveRecovery(quarantineDir)
|
|
587
|
+
recoveringOrphans.delete(sessionId)
|
|
588
|
+
lease.release()
|
|
589
|
+
})
|
|
590
|
+
scheduleBackground(task)
|
|
591
|
+
})
|
|
592
|
+
|
|
379
593
|
return router
|
|
380
594
|
}
|
|
381
595
|
|
|
596
|
+
const CHUNK_WAV_NAME = /^chunk_(\d{4})\.wav$/
|
|
597
|
+
const WAV_HEADER_BYTES = 44
|
|
598
|
+
const PCM_BYTES_PER_MS = 32 // 16 kHz mono 16-bit
|
|
599
|
+
|
|
600
|
+
interface RecoveredCapture {
|
|
601
|
+
entries: IndexedTranscriptChunk[]
|
|
602
|
+
/** Meeting start ≈ the earliest chunk's write time (chunk files keep their
|
|
603
|
+
* original mtimes across renames — the pending-batch cleanup relies on the
|
|
604
|
+
* same property). Falls back to now-minus-duration when mtimes are unusable. */
|
|
605
|
+
startTime: number
|
|
606
|
+
durationMs: number
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Rebuild a chunk timeline for a dead session from its WAV files alone:
|
|
610
|
+
* index from the filename, duration from PCM byte length, elapsed cumulative.
|
|
611
|
+
* Text is empty — recovery exists precisely because no ASR ever ran. */
|
|
612
|
+
function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
|
|
613
|
+
const rows: Array<{ chunkIndex: number; durationMs: number; mtimeMs: number }> = []
|
|
614
|
+
try {
|
|
615
|
+
for (const name of readdirSync(audioDir)) {
|
|
616
|
+
const match = CHUNK_WAV_NAME.exec(name)
|
|
617
|
+
if (!match) continue
|
|
618
|
+
try {
|
|
619
|
+
const stats = statSync(resolve(audioDir, name))
|
|
620
|
+
rows.push({
|
|
621
|
+
chunkIndex: Number(match[1]),
|
|
622
|
+
durationMs: Math.max(0, Math.round((stats.size - WAV_HEADER_BYTES) / PCM_BYTES_PER_MS)),
|
|
623
|
+
mtimeMs: stats.mtimeMs,
|
|
624
|
+
})
|
|
625
|
+
} catch { /* unreadable chunk — skip */ }
|
|
626
|
+
}
|
|
627
|
+
} catch {
|
|
628
|
+
return { entries: [], startTime: Date.now(), durationMs: 0 }
|
|
629
|
+
}
|
|
630
|
+
rows.sort((a, b) => a.chunkIndex - b.chunkIndex)
|
|
631
|
+
let elapsed = 0
|
|
632
|
+
const entries = rows.map(row => {
|
|
633
|
+
const entry: IndexedTranscriptChunk = {
|
|
634
|
+
chunkIndex: row.chunkIndex,
|
|
635
|
+
chunk: { text: '', speaker: 'Unknown', elapsed, similarity: 0 },
|
|
636
|
+
}
|
|
637
|
+
elapsed += row.durationMs
|
|
638
|
+
return entry
|
|
639
|
+
})
|
|
640
|
+
const durationMs = elapsed
|
|
641
|
+
const earliestMtime = rows.reduce(
|
|
642
|
+
(minimum, row) => (Number.isFinite(row.mtimeMs) && row.mtimeMs > 0 ? Math.min(minimum, row.mtimeMs) : minimum),
|
|
643
|
+
Number.POSITIVE_INFINITY,
|
|
644
|
+
)
|
|
645
|
+
const startTime = Number.isFinite(earliestMtime) && earliestMtime !== Number.POSITIVE_INFINITY
|
|
646
|
+
? Math.round(earliestMtime)
|
|
647
|
+
: Date.now() - durationMs
|
|
648
|
+
return { entries, startTime, durationMs }
|
|
649
|
+
}
|
|
650
|
+
|
|
382
651
|
async function finalizeBatch(options: {
|
|
383
652
|
audioDir: string
|
|
384
653
|
entries: IndexedTranscriptChunk[]
|
|
@@ -431,6 +700,16 @@ async function finalizeBatch(options: {
|
|
|
431
700
|
rmSync(options.audioDir, { recursive: true, force: true })
|
|
432
701
|
} else {
|
|
433
702
|
console.warn('[meeting/save] Pending raw audio retained for bounded cleanup')
|
|
703
|
+
// The batch reached a terminal outcome but its WAVs stay behind. Record it
|
|
704
|
+
// so meeting_sync reports "retained", not perpetual active work. Rejected
|
|
705
|
+
// and pipeline-failed runs already wrote their terminal inside runBatch;
|
|
706
|
+
// this covers the accepted-but-not-fully-persisted case.
|
|
707
|
+
if (result.transcriptionQuality === 'batch') {
|
|
708
|
+
writeMeetingBatchTerminal(options.audioDir, {
|
|
709
|
+
outcome: 'accepted',
|
|
710
|
+
reason: transcriptApplied ? 'metadata_persist_failed' : 'transcript_apply_failed',
|
|
711
|
+
})
|
|
712
|
+
}
|
|
434
713
|
}
|
|
435
714
|
}
|
|
436
715
|
|
|
@@ -35,6 +35,12 @@ import {
|
|
|
35
35
|
isVocabEchoOnly,
|
|
36
36
|
} from '../lib/hallucination-filter.js'
|
|
37
37
|
import { dataPath } from '../lib/data-dir.js'
|
|
38
|
+
import {
|
|
39
|
+
countChunkWavs,
|
|
40
|
+
purgeExpiredQuarantine,
|
|
41
|
+
quarantineSessionAudio,
|
|
42
|
+
sweepOrphanedSessionAudio,
|
|
43
|
+
} from '../lib/unsaved-audio-quarantine.js'
|
|
38
44
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
39
45
|
import {
|
|
40
46
|
LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
@@ -612,16 +618,28 @@ function recoverSessions(): void {
|
|
|
612
618
|
}
|
|
613
619
|
} catch { /* skip corrupt files */ }
|
|
614
620
|
}
|
|
615
|
-
//
|
|
621
|
+
// Orphaned session-audio dirs with no matching recovered session: audio
|
|
622
|
+
// evidence is QUARANTINED, never deleted (6.19.0 — two meetings were
|
|
623
|
+
// destroyed here on 2026-08-01 when their deferred saves never landed).
|
|
624
|
+
// Only chunk-less dirs are removed.
|
|
616
625
|
try {
|
|
617
626
|
if (existsSync(SESSION_AUDIO_DIR)) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
627
|
+
const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
|
|
628
|
+
isLive: id => recoveredIds.has(id),
|
|
629
|
+
hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
|
|
630
|
+
reason: 'boot_sweep_unsaved',
|
|
631
|
+
})
|
|
632
|
+
for (const entry of actions) {
|
|
633
|
+
if (entry.action === 'quarantined') {
|
|
634
|
+
console.warn(`[session-recovery] Quarantined unsaved session-audio: ${entry.dir} → ${entry.target}`)
|
|
635
|
+
} else if (entry.action === 'deleted_empty') {
|
|
636
|
+
console.log(`[session-recovery] Cleaned empty session-audio: ${entry.dir}`)
|
|
637
|
+
} else if (entry.action === 'quarantine_failed') {
|
|
638
|
+
console.error(`[session-recovery] Quarantine move failed, source retained: ${entry.dir}`)
|
|
622
639
|
}
|
|
623
640
|
}
|
|
624
641
|
}
|
|
642
|
+
purgeExpiredQuarantine()
|
|
625
643
|
} catch {}
|
|
626
644
|
} catch { /* non-critical */ }
|
|
627
645
|
}
|
|
@@ -658,13 +676,24 @@ setInterval(() => {
|
|
|
658
676
|
closeTranscriptSession(id, 'expired')
|
|
659
677
|
}
|
|
660
678
|
}
|
|
661
|
-
//
|
|
679
|
+
// Orphaned session-audio dirs (no matching active session): quarantine any
|
|
680
|
+
// dir still holding chunk audio; delete only chunk-less dirs. Quarantine
|
|
681
|
+
// itself expires on the unsaved-audio retention clock, the ONLY place
|
|
682
|
+
// quarantined audio is ever deleted.
|
|
662
683
|
try {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
684
|
+
const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
|
|
685
|
+
isLive: id => sessions.has(id),
|
|
686
|
+
hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
|
|
687
|
+
reason: 'idle_expiry_unsaved',
|
|
688
|
+
})
|
|
689
|
+
for (const entry of actions) {
|
|
690
|
+
if (entry.action === 'quarantined') {
|
|
691
|
+
console.warn(`[cleanup] Quarantined unsaved session-audio: ${entry.dir} → ${entry.target}`)
|
|
692
|
+
} else if (entry.action === 'quarantine_failed') {
|
|
693
|
+
console.error(`[cleanup] Quarantine move failed, source retained: ${entry.dir}`)
|
|
666
694
|
}
|
|
667
695
|
}
|
|
696
|
+
purgeExpiredQuarantine()
|
|
668
697
|
} catch {}
|
|
669
698
|
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
670
699
|
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|
|
@@ -1022,7 +1051,7 @@ function closeTranscriptSession(
|
|
|
1022
1051
|
maxChunkIndex,
|
|
1023
1052
|
reason,
|
|
1024
1053
|
})
|
|
1025
|
-
finishClosingTranscriptSession(sessionId, options)
|
|
1054
|
+
finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
|
|
1026
1055
|
}
|
|
1027
1056
|
|
|
1028
1057
|
/** Delete session after save */
|
|
@@ -1030,7 +1059,10 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
|
|
|
1030
1059
|
closeTranscriptSession(sessionId, 'saved', options)
|
|
1031
1060
|
}
|
|
1032
1061
|
|
|
1033
|
-
function finishClosingTranscriptSession(
|
|
1062
|
+
function finishClosingTranscriptSession(
|
|
1063
|
+
sessionId: string,
|
|
1064
|
+
options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
|
|
1065
|
+
): void {
|
|
1034
1066
|
sessions.delete(sessionId)
|
|
1035
1067
|
sessionAudioBytes.delete(sessionId)
|
|
1036
1068
|
sessionAudioWrites.delete(sessionId)
|
|
@@ -1049,8 +1081,28 @@ function finishClosingTranscriptSession(sessionId: string, options: { preserveAu
|
|
|
1049
1081
|
writeFileSync(marker, String(Date.now()), { encoding: 'utf8', mode: 0o600 })
|
|
1050
1082
|
chmodSync(marker, 0o600)
|
|
1051
1083
|
} catch {}
|
|
1052
|
-
} else {
|
|
1053
|
-
|
|
1084
|
+
} else if (options.closeReason === 'saved') {
|
|
1085
|
+
// Saved sessions moved their audio to pending-batch (or explicitly
|
|
1086
|
+
// preserved it above); a leftover dir here is residue, safe to delete.
|
|
1087
|
+
// UNLESS chunks are still present — hasAudio() returns false on a
|
|
1088
|
+
// transient statSync error, which makes preserveAudio false while the
|
|
1089
|
+
// move never ran. Chunk-bearing evidence goes to quarantine, never rmSync.
|
|
1090
|
+
if (countChunkWavs(audioDir) > 0) {
|
|
1091
|
+
const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
|
|
1092
|
+
if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
|
|
1093
|
+
} else {
|
|
1094
|
+
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
1095
|
+
}
|
|
1096
|
+
} else if (existsSync(audioDir)) {
|
|
1097
|
+
// Any non-saved close (expired, error, …) with chunk audio still on disk
|
|
1098
|
+
// is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
|
|
1099
|
+
if (countChunkWavs(audioDir) > 0) {
|
|
1100
|
+
const target = quarantineSessionAudio(audioDir, `close_${options.closeReason ?? 'unknown'}`)
|
|
1101
|
+
if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
|
|
1102
|
+
else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
|
|
1103
|
+
} else {
|
|
1104
|
+
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
1105
|
+
}
|
|
1054
1106
|
}
|
|
1055
1107
|
}
|
|
1056
1108
|
|