@gotcos/glasses-server 6.21.7 → 6.21.9
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 +14 -0
- package/CHANGELOG.md +23 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/batch-transcript-quality.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +23 -6
- package/server/lib/g2-ops-handoff.ts +193 -32
- package/server/lib/meeting-batch-transcribe.ts +554 -10
- package/server/lib/meeting-finalization-jobs.ts +235 -0
- package/server/lib/meeting-store.ts +9 -0
- package/server/lib/whisper-local.ts +77 -6
- package/server/routes/health.ts +15 -0
- package/server/routes/meeting.ts +268 -52
- package/server/routes/transcribe-stream.ts +67 -2
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
3
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
4
|
+
import { dataPath } from './data-dir.js'
|
|
5
|
+
|
|
6
|
+
const JOB_NAME = /^[A-Za-z0-9:_-]{3,96}\.json$/
|
|
7
|
+
|
|
8
|
+
export interface MeetingFinalizationJob {
|
|
9
|
+
schemaVersion: 1
|
|
10
|
+
sessionId: string
|
|
11
|
+
meetingPath: string
|
|
12
|
+
sidecarPath: string
|
|
13
|
+
audioDir: string | null
|
|
14
|
+
streamingWordCount: number
|
|
15
|
+
phase: 'capture_pending' | 'batch_pending' | 'ops_pending'
|
|
16
|
+
claimPending: boolean
|
|
17
|
+
createdAt: string
|
|
18
|
+
updatedAt: string
|
|
19
|
+
lastError?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function contained(parent: string, child: string): boolean {
|
|
23
|
+
return child === parent || child.startsWith(`${parent}${sep}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validJob(raw: unknown): raw is MeetingFinalizationJob {
|
|
27
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
|
|
28
|
+
const job = raw as Partial<MeetingFinalizationJob>
|
|
29
|
+
return job.schemaVersion === 1
|
|
30
|
+
&& typeof job.sessionId === 'string'
|
|
31
|
+
&& /^[A-Za-z0-9:_-]{3,96}$/.test(job.sessionId)
|
|
32
|
+
&& typeof job.meetingPath === 'string'
|
|
33
|
+
&& typeof job.sidecarPath === 'string'
|
|
34
|
+
&& (job.audioDir === null || typeof job.audioDir === 'string')
|
|
35
|
+
&& typeof job.streamingWordCount === 'number'
|
|
36
|
+
&& Number.isFinite(job.streamingWordCount)
|
|
37
|
+
&& (job.phase === 'capture_pending' || job.phase === 'batch_pending' || job.phase === 'ops_pending')
|
|
38
|
+
&& (job.claimPending === undefined || typeof job.claimPending === 'boolean')
|
|
39
|
+
&& typeof job.createdAt === 'string'
|
|
40
|
+
&& typeof job.updatedAt === 'string'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Durable replay ledger for the post-response HQ + operations handoff.
|
|
44
|
+
* The meeting and sidecar remain the canonical data; this store contains only
|
|
45
|
+
* bounded pointers and phase state so a server restart can resume safely. */
|
|
46
|
+
export class MeetingFinalizationJobStore {
|
|
47
|
+
readonly root: string
|
|
48
|
+
|
|
49
|
+
constructor(root = dataPath('meeting-finalization-jobs')) {
|
|
50
|
+
this.root = resolve(root)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private ensureRoot(): void {
|
|
54
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private pathFor(sessionId: string): string {
|
|
58
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) throw new Error('Invalid finalization sessionId')
|
|
59
|
+
const path = resolve(this.root, `${sessionId}.json`)
|
|
60
|
+
if (!contained(this.root, path)) throw new Error('Unsafe finalization job path')
|
|
61
|
+
return path
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private assertSafePointers(input: Pick<MeetingFinalizationJob, 'meetingPath' | 'sidecarPath' | 'audioDir'>): void {
|
|
65
|
+
const dataRoot = dirname(this.root)
|
|
66
|
+
const recordingsRoot = resolve(dataRoot, 'recordings')
|
|
67
|
+
const pendingRoot = resolve(dataRoot, 'pending-batch')
|
|
68
|
+
if (!contained(recordingsRoot, resolve(input.meetingPath))) throw new Error('Unsafe finalization meeting path')
|
|
69
|
+
if (!contained(recordingsRoot, resolve(input.sidecarPath))) throw new Error('Unsafe finalization sidecar path')
|
|
70
|
+
if (input.audioDir && !contained(pendingRoot, resolve(input.audioDir))) {
|
|
71
|
+
throw new Error('Unsafe finalization audio path')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
save(input: Omit<MeetingFinalizationJob, 'schemaVersion' | 'createdAt' | 'updatedAt'>): MeetingFinalizationJob {
|
|
76
|
+
this.assertSafePointers(input)
|
|
77
|
+
this.ensureRoot()
|
|
78
|
+
const prior = this.get(input.sessionId)
|
|
79
|
+
const now = new Date().toISOString()
|
|
80
|
+
const job: MeetingFinalizationJob = {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
...input,
|
|
83
|
+
createdAt: prior?.createdAt ?? now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
}
|
|
86
|
+
durableAtomicWriteFileSync(this.pathFor(input.sessionId), `${JSON.stringify(job, null, 2)}\n`, { mode: 0o600 })
|
|
87
|
+
return job
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get(sessionId: string): MeetingFinalizationJob | null {
|
|
91
|
+
const path = this.pathFor(sessionId)
|
|
92
|
+
if (!existsSync(path)) return null
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
95
|
+
if (!validJob(parsed)) return null
|
|
96
|
+
const normalized = { ...parsed, claimPending: parsed.claimPending === true }
|
|
97
|
+
this.assertSafePointers(normalized)
|
|
98
|
+
return normalized
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
list(): MeetingFinalizationJob[] {
|
|
105
|
+
if (!existsSync(this.root)) return []
|
|
106
|
+
let names: string[] = []
|
|
107
|
+
try { names = readdirSync(this.root).filter(name => JOB_NAME.test(name)) } catch { return [] }
|
|
108
|
+
return names.flatMap(name => {
|
|
109
|
+
const sessionId = basename(name, '.json')
|
|
110
|
+
const job = this.get(sessionId)
|
|
111
|
+
return job ? [job] : []
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
malformedCount(): number {
|
|
116
|
+
if (!existsSync(this.root)) return 0
|
|
117
|
+
try {
|
|
118
|
+
return readdirSync(this.root)
|
|
119
|
+
.filter(name => JOB_NAME.test(name))
|
|
120
|
+
.filter(name => this.get(basename(name, '.json')) === null)
|
|
121
|
+
.length
|
|
122
|
+
} catch {
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
remove(sessionId: string): void {
|
|
128
|
+
try { unlinkSync(this.pathFor(sessionId)) } catch { /* already absent */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Find a previously moved audio directory without trusting a stored path. */
|
|
132
|
+
findPendingAudioDir(sessionId: string): string | null {
|
|
133
|
+
const pendingRoot = resolve(dirname(this.root), 'pending-batch')
|
|
134
|
+
if (!existsSync(pendingRoot)) return null
|
|
135
|
+
try {
|
|
136
|
+
const candidates = readdirSync(pendingRoot)
|
|
137
|
+
.filter(name => name === sessionId || name.startsWith(`${sessionId}_`))
|
|
138
|
+
.map(name => resolve(pendingRoot, name))
|
|
139
|
+
.filter(path => contained(pendingRoot, path) && statSync(path).isDirectory())
|
|
140
|
+
.sort()
|
|
141
|
+
return candidates[0] ?? null
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Rebuild missing replay jobs from the canonical sidecar intent marker. */
|
|
148
|
+
reconcileCanonicalSidecars(): MeetingFinalizationJob[] {
|
|
149
|
+
const recordingsRoot = resolve(dirname(this.root), 'recordings')
|
|
150
|
+
if (!existsSync(recordingsRoot)) return []
|
|
151
|
+
const rebuilt: MeetingFinalizationJob[] = []
|
|
152
|
+
let months: string[] = []
|
|
153
|
+
try { months = readdirSync(recordingsRoot).filter(name => /^\d{4}-\d{2}$/.test(name)) } catch { return [] }
|
|
154
|
+
for (const month of months) {
|
|
155
|
+
const monthDir = resolve(recordingsRoot, month)
|
|
156
|
+
let names: string[] = []
|
|
157
|
+
try { names = readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')) } catch { continue }
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const sidecarPath = resolve(monthDir, name)
|
|
160
|
+
try {
|
|
161
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
|
|
162
|
+
const sessionId = typeof sidecar.sessionId === 'string' ? sidecar.sessionId : ''
|
|
163
|
+
if (sidecar.finalizationState === 'complete' || !sidecar.finalizationState || this.get(sessionId)) continue
|
|
164
|
+
const meetingPath = sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
165
|
+
if (!existsSync(meetingPath)) continue
|
|
166
|
+
const audioDir = this.findPendingAudioDir(sessionId)
|
|
167
|
+
rebuilt.push(this.save({
|
|
168
|
+
sessionId,
|
|
169
|
+
meetingPath,
|
|
170
|
+
sidecarPath,
|
|
171
|
+
audioDir,
|
|
172
|
+
streamingWordCount: Number(sidecar.streamingWordCount ?? 0),
|
|
173
|
+
phase: audioDir ? 'batch_pending' : 'capture_pending',
|
|
174
|
+
claimPending: sidecar.claimPending === true,
|
|
175
|
+
}))
|
|
176
|
+
} catch { /* malformed canonical sidecars are not executable */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return rebuilt
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function readFinalizationChunkEntries(job: MeetingFinalizationJob): unknown[] | null {
|
|
184
|
+
if (!existsSync(job.meetingPath) || !existsSync(job.sidecarPath)) return null
|
|
185
|
+
try {
|
|
186
|
+
const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
|
|
187
|
+
if (sidecar.sessionId !== job.sessionId || !Array.isArray(sidecar.chunkEntries)) return null
|
|
188
|
+
return sidecar.chunkEntries
|
|
189
|
+
} catch {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function canonicalFinalizationIsComplete(job: MeetingFinalizationJob): boolean {
|
|
195
|
+
try {
|
|
196
|
+
const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
|
|
197
|
+
return sidecar.sessionId === job.sessionId && sidecar.finalizationState === 'complete'
|
|
198
|
+
} catch {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function markCanonicalFinalizationState(
|
|
204
|
+
sidecarPath: string,
|
|
205
|
+
state: MeetingFinalizationJob['phase'] | 'complete',
|
|
206
|
+
claimPending: boolean,
|
|
207
|
+
): void {
|
|
208
|
+
if (!existsSync(sidecarPath)) return
|
|
209
|
+
const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
|
|
210
|
+
parsed.finalizationState = state
|
|
211
|
+
parsed.claimPending = claimPending
|
|
212
|
+
parsed.finalizationUpdatedAt = new Date().toISOString()
|
|
213
|
+
if (state === 'complete') delete parsed.finalizationError
|
|
214
|
+
durableAtomicWriteFileSync(sidecarPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getMeetingFinalizationSnapshot(): {
|
|
218
|
+
pending: number
|
|
219
|
+
failed: number
|
|
220
|
+
oldestUpdatedAt: string | null
|
|
221
|
+
lastError: string | null
|
|
222
|
+
malformed: number
|
|
223
|
+
} {
|
|
224
|
+
const jobs = new MeetingFinalizationJobStore().list()
|
|
225
|
+
const failed = jobs.filter(job => Boolean(job.lastError))
|
|
226
|
+
const oldest = jobs.map(job => job.updatedAt).sort()[0] ?? null
|
|
227
|
+
const mostRecentFailure = failed.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
|
|
228
|
+
return {
|
|
229
|
+
pending: jobs.length,
|
|
230
|
+
failed: failed.length,
|
|
231
|
+
oldestUpdatedAt: oldest,
|
|
232
|
+
lastError: mostRecentFailure?.lastError?.slice(0, 200) ?? null,
|
|
233
|
+
malformed: new MeetingFinalizationJobStore().malformedCount(),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -89,6 +89,10 @@ export interface SaveMeetingInput {
|
|
|
89
89
|
chunkEntries?: IndexedTranscriptChunk[]
|
|
90
90
|
providerCandidates?: Record<string, ProviderCandidateRecord>
|
|
91
91
|
transferIntegrity?: TranscriptGapReport | null
|
|
92
|
+
/** Durable intent used to reconstruct post-save work if the process exits
|
|
93
|
+
* between the canonical commit and creation of the replay job. */
|
|
94
|
+
finalizationRequired?: boolean
|
|
95
|
+
claimPending?: boolean
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export interface SavedMeeting {
|
|
@@ -444,6 +448,11 @@ export class MeetingStore {
|
|
|
444
448
|
transcriptionQuality: 'streaming',
|
|
445
449
|
batchApplied: false,
|
|
446
450
|
streamingWordCount: wordCount(transcript),
|
|
451
|
+
...(input.finalizationRequired ? {
|
|
452
|
+
finalizationState: 'capture_pending',
|
|
453
|
+
claimPending: input.claimPending === true,
|
|
454
|
+
finalizationUpdatedAt: new Date().toISOString(),
|
|
455
|
+
} : {}),
|
|
447
456
|
}
|
|
448
457
|
|
|
449
458
|
// Sidecar first, markdown second: the markdown is the visible commit marker.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { spawn, execFile } from 'node:child_process'
|
|
10
10
|
import type { ChildProcess } from 'node:child_process'
|
|
11
|
-
import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { writeFileSync, unlinkSync, existsSync, readFileSync, statSync } from 'node:fs'
|
|
12
12
|
import { basename, join } from 'node:path'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
@@ -74,6 +74,37 @@ export interface HighQualityTranscriptionResult {
|
|
|
74
74
|
degradationReason?: HighQualityUnavailableReason
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
let highQualityCheckpointFingerprint: string | null = null
|
|
78
|
+
|
|
79
|
+
/** Stable, path-free cache identity for progressive meeting HQ checkpoints.
|
|
80
|
+
* Model/configuration is immutable for the lifetime of one server process, so
|
|
81
|
+
* cache this instead of stat'ing multi-GB model files on every health poll. */
|
|
82
|
+
export function getHighQualityCheckpointFingerprint(): string {
|
|
83
|
+
if (highQualityCheckpointFingerprint) return highQualityCheckpointFingerprint
|
|
84
|
+
const identity = (path: string): Record<string, number | boolean> => {
|
|
85
|
+
try {
|
|
86
|
+
const stat = statSync(path)
|
|
87
|
+
return { present: true, size: stat.size, mtimeMs: Math.floor(stat.mtimeMs) }
|
|
88
|
+
} catch {
|
|
89
|
+
return { present: false, size: 0, mtimeMs: 0 }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
highQualityCheckpointFingerprint = crypto.createHash('sha256').update(JSON.stringify({
|
|
93
|
+
schema: 2,
|
|
94
|
+
serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
|
|
95
|
+
whisperCli: identity(WHISPER_CLI),
|
|
96
|
+
model: identity(resolveBatchModel()),
|
|
97
|
+
vad: identity(VAD_MODEL_PATH),
|
|
98
|
+
beam: 5,
|
|
99
|
+
bestOf: 5,
|
|
100
|
+
vadEnabled: hqCliVadEnabled('batch'),
|
|
101
|
+
enhancement: 'audio-enhance-v1',
|
|
102
|
+
prompt: buildWhisperPrompt(),
|
|
103
|
+
corrections: getWhisperCorrections(),
|
|
104
|
+
})).digest('hex')
|
|
105
|
+
return highQualityCheckpointFingerprint
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
export function classifyHighQualityTranscriptionCapability(input: {
|
|
78
109
|
enabled: boolean
|
|
79
110
|
cliPresent: boolean
|
|
@@ -806,7 +837,14 @@ export async function transcribeHighQuality(
|
|
|
806
837
|
/** forceCpu: the batch pipeline's one CPU retry after a Metal preempt. It
|
|
807
838
|
* bypasses the gate entirely so the retry cannot itself be preempted into
|
|
808
839
|
* an infinite loop. */
|
|
809
|
-
opts: {
|
|
840
|
+
opts: {
|
|
841
|
+
priority?: 'interactive' | 'batch'
|
|
842
|
+
forceCpu?: boolean
|
|
843
|
+
forceCpuReason?: string
|
|
844
|
+
threads?: number
|
|
845
|
+
backgroundCpu?: boolean
|
|
846
|
+
signal?: AbortSignal
|
|
847
|
+
} = {},
|
|
810
848
|
): Promise<HighQualityTranscriptionResult> {
|
|
811
849
|
if (!cliAvailable) {
|
|
812
850
|
// Fall back to server (no beam search available via HTTP API)
|
|
@@ -844,7 +882,7 @@ export async function transcribeHighQuality(
|
|
|
844
882
|
const useVad = hqCliVadEnabled(opts.priority)
|
|
845
883
|
const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
|
|
846
884
|
? (opts.forceCpu
|
|
847
|
-
? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
|
|
885
|
+
? { device: 'cpu', reason: opts.forceCpuReason || 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
|
|
848
886
|
: chooseBatchDevice())
|
|
849
887
|
: { device: 'metal', reason: 'interactive', metalEnabled: batchHqMetalEnabled() }
|
|
850
888
|
const useMetal = decision.device === 'metal'
|
|
@@ -858,11 +896,14 @@ export async function transcribeHighQuality(
|
|
|
858
896
|
: 2
|
|
859
897
|
const beam = isBatch ? 5 : interactiveBeam
|
|
860
898
|
const bestOf = beam
|
|
899
|
+
const requestedThreads = Number.isFinite(opts.threads)
|
|
900
|
+
? Math.max(1, Math.min(16, Math.floor(opts.threads!)))
|
|
901
|
+
: 8
|
|
861
902
|
const args = [
|
|
862
903
|
'-m', modelPath,
|
|
863
904
|
'-f', tmpWav,
|
|
864
905
|
// CPU batch stays at 8 threads so it cannot starve live work of cores.
|
|
865
|
-
'-t', (isBatch && !useMetal) ?
|
|
906
|
+
'-t', (isBatch && !useMetal) ? String(requestedThreads) : '16',
|
|
866
907
|
'-l', 'en',
|
|
867
908
|
...(useMetal ? ['-fa'] : ['-ng']),
|
|
868
909
|
'-bs', String(beam),
|
|
@@ -879,9 +920,18 @@ export async function transcribeHighQuality(
|
|
|
879
920
|
// omits this — VAD was measured dropping real leading speech on compose.
|
|
880
921
|
args.push('--vad', '--vad-model', VAD_MODEL_PATH)
|
|
881
922
|
}
|
|
882
|
-
const
|
|
923
|
+
const useBackgroundTaskPolicy = Boolean(
|
|
924
|
+
isBatch && !useMetal && opts.backgroundCpu
|
|
925
|
+
&& process.platform === 'darwin'
|
|
926
|
+
&& existsSync('/usr/sbin/taskpolicy'),
|
|
927
|
+
)
|
|
928
|
+
const proc = spawn(
|
|
929
|
+
useBackgroundTaskPolicy ? '/usr/sbin/taskpolicy' : WHISPER_CLI,
|
|
930
|
+
useBackgroundTaskPolicy ? ['-b', WHISPER_CLI, ...args] : args,
|
|
931
|
+
{
|
|
883
932
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
884
|
-
|
|
933
|
+
},
|
|
934
|
+
)
|
|
885
935
|
ownedHqChildren.add(proc)
|
|
886
936
|
|
|
887
937
|
// BLOCKER contract: a preempted Metal child is a HARD FAIL. Its stdout
|
|
@@ -896,6 +946,17 @@ export async function transcribeHighQuality(
|
|
|
896
946
|
|
|
897
947
|
let stdout = ''
|
|
898
948
|
let stderr = ''
|
|
949
|
+
let aborted = false
|
|
950
|
+
let abortForceKill: ReturnType<typeof setTimeout> | null = null
|
|
951
|
+
const onAbort = (): void => {
|
|
952
|
+
aborted = true
|
|
953
|
+
try { proc.kill('SIGTERM') } catch { /* already exited */ }
|
|
954
|
+
abortForceKill = setTimeout(() => {
|
|
955
|
+
try { proc.kill('SIGKILL') } catch { /* already exited */ }
|
|
956
|
+
}, 2_000)
|
|
957
|
+
}
|
|
958
|
+
if (opts.signal?.aborted) onAbort()
|
|
959
|
+
else opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
899
960
|
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
|
900
961
|
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
|
901
962
|
|
|
@@ -915,9 +976,11 @@ export async function transcribeHighQuality(
|
|
|
915
976
|
}, timeoutMs)
|
|
916
977
|
|
|
917
978
|
proc.on('close', (code) => {
|
|
979
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
918
980
|
ownedHqChildren.delete(proc)
|
|
919
981
|
unregisterMetalBatchChild(proc)
|
|
920
982
|
clearTimeout(timeout)
|
|
983
|
+
if (abortForceKill) clearTimeout(abortForceKill)
|
|
921
984
|
if (forceKill) clearTimeout(forceKill)
|
|
922
985
|
// Preempt is checked FIRST and ignores the exit code: SIGTERM often
|
|
923
986
|
// yields a non-zero code, but a race could also let the child exit 0
|
|
@@ -926,6 +989,12 @@ export async function transcribeHighQuality(
|
|
|
926
989
|
reject(new MetalBatchPreemptedError(preemptedReason))
|
|
927
990
|
return
|
|
928
991
|
}
|
|
992
|
+
if (aborted) {
|
|
993
|
+
const error = new Error('Progressive HQ checkpoint aborted')
|
|
994
|
+
error.name = 'AbortError'
|
|
995
|
+
reject(error)
|
|
996
|
+
return
|
|
997
|
+
}
|
|
929
998
|
if (timedOut) {
|
|
930
999
|
reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
|
|
931
1000
|
return
|
|
@@ -938,9 +1007,11 @@ export async function transcribeHighQuality(
|
|
|
938
1007
|
})
|
|
939
1008
|
|
|
940
1009
|
proc.on('error', (err) => {
|
|
1010
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
941
1011
|
ownedHqChildren.delete(proc)
|
|
942
1012
|
unregisterMetalBatchChild(proc)
|
|
943
1013
|
clearTimeout(timeout)
|
|
1014
|
+
if (abortForceKill) clearTimeout(abortForceKill)
|
|
944
1015
|
if (forceKill) clearTimeout(forceKill)
|
|
945
1016
|
reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
|
|
946
1017
|
})
|
package/server/routes/health.ts
CHANGED
|
@@ -40,6 +40,9 @@ import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
|
|
|
40
40
|
import { getWhisperPreviewCapability } from '../lib/whisper-preview.js'
|
|
41
41
|
import { getTranscriptionProfileStatus } from '../lib/profile.js'
|
|
42
42
|
import { getHealthStaticProbes } from '../lib/health-static-probes.js'
|
|
43
|
+
import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
|
|
44
|
+
import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
|
|
45
|
+
import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
|
|
43
46
|
|
|
44
47
|
export const healthRouter = Router()
|
|
45
48
|
|
|
@@ -190,6 +193,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
190
193
|
const cursorSnapshot = getCursorModelCatalogSnapshot()
|
|
191
194
|
const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
|
|
192
195
|
const meeting_sync = getMeetingSyncSnapshot()
|
|
196
|
+
const progressiveHq = getProgressiveHqSnapshot()
|
|
193
197
|
// Quarantined unsaved captures (6.19.0). Compact on this unauthenticated
|
|
194
198
|
// surface — same exposure level as meeting_sync's meetingIds. Full detail
|
|
195
199
|
// plus the recover action live on the authenticated /api/meeting/orphans.
|
|
@@ -235,6 +239,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
235
239
|
},
|
|
236
240
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
237
241
|
liveCues,
|
|
242
|
+
meetingLifecycle: {
|
|
243
|
+
earlySyncClaim: getEarlyMeetingSyncSnapshot(),
|
|
244
|
+
progressiveHq,
|
|
245
|
+
finalization: getMeetingFinalizationSnapshot(),
|
|
246
|
+
},
|
|
238
247
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
239
248
|
},
|
|
240
249
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
@@ -261,6 +270,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
261
270
|
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
262
271
|
const transcriptionLive = getWhisperPreviewCapability()
|
|
263
272
|
const transcriptionProfile = getTranscriptionProfileStatus()
|
|
273
|
+
const progressiveHq = getProgressiveHqSnapshot()
|
|
264
274
|
const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
|
|
265
275
|
res.json({
|
|
266
276
|
...catalog,
|
|
@@ -282,6 +292,11 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
282
292
|
hq: transcriptionHq,
|
|
283
293
|
profile: transcriptionProfile,
|
|
284
294
|
},
|
|
295
|
+
meetingLifecycle: {
|
|
296
|
+
earlySyncClaim: getEarlyMeetingSyncSnapshot(),
|
|
297
|
+
progressiveHq,
|
|
298
|
+
finalization: getMeetingFinalizationSnapshot(),
|
|
299
|
+
},
|
|
285
300
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
286
301
|
recovery: managedRuntimeCapability(),
|
|
287
302
|
// Same helper as /api/health — the companion's 15s liveness poll reads
|