@gotcos/glasses-server 6.94.2 → 6.95.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/CHANGELOG.md +14 -0
- package/README.md +52 -0
- package/package.json +2 -1
- package/server/index.ts +13 -1
- package/server/lib/speaker-shadow-decide.ts +466 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +277 -0
- package/server/lib/speaker-shadow.ts +727 -0
- package/server/routes/health.ts +5 -0
- package/server/routes/meeting.ts +4 -0
- package/server/routes/speaker-shadow.ts +79 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// Speaker merge shadow mode (6.95.0). OFF BY DEFAULT. LOG ONLY.
|
|
2
|
+
//
|
|
3
|
+
// WHY. Mac recordings split one person into many anonymous `Voice N` labels (45 on one
|
|
4
|
+
// 60-minute call). A research canary found a hybrid that fixes this safely on the
|
|
5
|
+
// reference call: production labels stay the base and FluidAudio's offline Community-1
|
|
6
|
+
// diarizer only PROPOSES merges, behind the gates in speaker-shadow-decide.ts. Shadow mode
|
|
7
|
+
// computes and logs those proposals for every finished Mac meeting so they can be scored
|
|
8
|
+
// against Miles's later corrections. There is no write path, and none may be added here.
|
|
9
|
+
//
|
|
10
|
+
// SWITCH, per Mac, in the transcript-cleanup pattern (6.77.0), off for everyone by default:
|
|
11
|
+
// 1. env COS_SPEAKER_SHADOW=1|true|yes|on forces it on, =0|false|no|off forces it off
|
|
12
|
+
// (source 'env'); a request to change it then answers 409 env_forced;
|
|
13
|
+
// 2. the saved setting <data>/speaker-shadow/settings.json (atomic), written through the
|
|
14
|
+
// token-protected POST /api/speaker-shadow/settings {"enabled":true|false}, read at use
|
|
15
|
+
// time, so no restart is needed (source 'setting');
|
|
16
|
+
// 3. off (source 'default').
|
|
17
|
+
// An unreadable settings file reads as no setting, which is off. Off means nothing is queued,
|
|
18
|
+
// nothing is spawned and no file is created. Turning it off mid-run stops the run.
|
|
19
|
+
//
|
|
20
|
+
// HOW IT STOPS.
|
|
21
|
+
// - the switch (remove it and restart);
|
|
22
|
+
// - a daily cap on runs (COS_SPEAKER_SHADOW_DAILY_RUNS, default 12);
|
|
23
|
+
// - a breaker: 3 consecutive failures pause it for 1 hour;
|
|
24
|
+
// - a hard wall clock (180 s, COS_SPEAKER_SHADOW_TIMEOUT_MS may only lower it) and an
|
|
25
|
+
// RSS cap on the whole process group (COS_SPEAKER_SHADOW_RSS_MB, default 3072);
|
|
26
|
+
// - live work: a recording, a save, a batch finalization or a one-shot transcription
|
|
27
|
+
// defers it, and one that starts mid-run kills the run and puts the meeting back.
|
|
28
|
+
//
|
|
29
|
+
// No LLM and no network at run time: the CLI runs under sandbox-exec with network denied,
|
|
30
|
+
// and only after the pinned models verify (speaker-shadow-models.ts).
|
|
31
|
+
//
|
|
32
|
+
// WHAT IT WRITES, all under <data>/speaker-shadow/: proposals.jsonl (one record per
|
|
33
|
+
// meeting, atomic rewrite, rotated past 8 MB), state.json (queue, cap, breaker), and the
|
|
34
|
+
// CLI's private HOME. Plus a temp directory the worker removes. Never a meeting file, a
|
|
35
|
+
// label, a name, a profile, an embedding cache or the correction ledger.
|
|
36
|
+
|
|
37
|
+
import { execFile, spawn, type ChildProcess } from 'node:child_process'
|
|
38
|
+
import { createHash } from 'node:crypto'
|
|
39
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
40
|
+
import { open, readFile, readdir, rename, unlink } from 'node:fs/promises'
|
|
41
|
+
import { createRequire } from 'node:module'
|
|
42
|
+
import { setPriority, tmpdir } from 'node:os'
|
|
43
|
+
import { dirname, join } from 'node:path'
|
|
44
|
+
import { pipeline } from 'node:stream/promises'
|
|
45
|
+
import { Transform } from 'node:stream'
|
|
46
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
47
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
48
|
+
import { MAC_CAPTURE_ID } from './mac-recording.js'
|
|
49
|
+
import { maintenanceLifecycle } from './maintenance-lifecycle.js'
|
|
50
|
+
import { installedCliIntact, installedCliTrust, parakeetCliPath, parakeetInstalled, SANDBOX_EXEC } from './parakeet.js'
|
|
51
|
+
import { SHADOW_GATES } from './speaker-shadow-decide.js'
|
|
52
|
+
import { shadowModelsPresent, speakerShadowHome, speakerShadowModelsDir, speakerShadowRoot } from './speaker-shadow-models.js'
|
|
53
|
+
import { readShadowMeetingTitle, shadowEntries, shadowLabelsHash, shadowMeetingDay } from './speaker-shadow-timeline.js'
|
|
54
|
+
import { renderShadowRunFacts, renderShadowSkipReport, shadowReportRelativePath } from './speaker-shadow-report.js'
|
|
55
|
+
import { SHADOW_TMP_PREFIX, type ShadowJob, type ShadowJobOutcome, type ShadowRunFacts } from './speaker-shadow-worker.js'
|
|
56
|
+
import type { ShadowProposals } from './speaker-shadow-decide.js'
|
|
57
|
+
|
|
58
|
+
export const SPEAKER_SHADOW_ENV = 'COS_SPEAKER_SHADOW'
|
|
59
|
+
export const SPEAKER_SHADOW_RECORDS = 'proposals.jsonl'
|
|
60
|
+
export const SPEAKER_SHADOW_STATE = 'state.json'
|
|
61
|
+
/** The wall clock the brief fixes (the canary took about 20 s for 60 minutes). */
|
|
62
|
+
export const SPEAKER_SHADOW_MAX_TIMEOUT_MS = 180_000
|
|
63
|
+
export const SPEAKER_SHADOW_BREAKER_FAILURES = 3
|
|
64
|
+
export const SPEAKER_SHADOW_BREAKER_PAUSE_MS = 60 * 60_000
|
|
65
|
+
export const SPEAKER_SHADOW_RECORDS_ROTATE_BYTES = 8 * 1024 * 1024
|
|
66
|
+
const MAX_QUEUE = 25
|
|
67
|
+
const QUEUE_MAX_AGE_MS = 24 * 60 * 60_000
|
|
68
|
+
const DEFER_RETRY_MS = 60_000
|
|
69
|
+
const SAMPLE_MS = 1_000
|
|
70
|
+
const MAX_STDOUT = 8 * 1024 * 1024
|
|
71
|
+
|
|
72
|
+
export const SPEAKER_SHADOW_SETTINGS = 'settings.json'
|
|
73
|
+
const ENV_ON = new Set(['1', 'true', 'yes', 'on'])
|
|
74
|
+
const ENV_OFF = new Set(['0', 'false', 'no', 'off'])
|
|
75
|
+
export type SpeakerShadowSwitchSource = 'env' | 'setting' | 'default'
|
|
76
|
+
|
|
77
|
+
/** What the environment pins, or null when it pins nothing (unset, empty or unrecognised). */
|
|
78
|
+
export function speakerShadowEnvSwitch(env: NodeJS.ProcessEnv = process.env): boolean | null {
|
|
79
|
+
const raw = (env[SPEAKER_SHADOW_ENV] ?? '').trim().toLowerCase()
|
|
80
|
+
return ENV_ON.has(raw) ? true : ENV_OFF.has(raw) ? false : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const settingsCache = new Map<string, { key: string; value: boolean | null }>()
|
|
84
|
+
/** The saved per-Mac setting: true, false, or null when there is none or it cannot be read. */
|
|
85
|
+
export function readSpeakerShadowSetting(root: string = speakerShadowRoot()): boolean | null {
|
|
86
|
+
const file = join(root, SPEAKER_SHADOW_SETTINGS)
|
|
87
|
+
try {
|
|
88
|
+
const st = statSync(file)
|
|
89
|
+
const key = `${st.mtimeMs}:${st.size}`
|
|
90
|
+
const cached = settingsCache.get(file)
|
|
91
|
+
if (cached?.key === key) return cached.value
|
|
92
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
93
|
+
const value = raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.enabled === 'boolean' ? raw.enabled : null
|
|
94
|
+
settingsCache.set(file, { key, value })
|
|
95
|
+
return value
|
|
96
|
+
} catch { return null }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Atomic and durable. The only writer of the setting. */
|
|
100
|
+
export function writeSpeakerShadowSetting(enabled: boolean, root: string = speakerShadowRoot(), now: Date = new Date()): void {
|
|
101
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
102
|
+
durableAtomicWriteFileSync(join(root, SPEAKER_SHADOW_SETTINGS), `${JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() })}\n`, { mode: 0o600 })
|
|
103
|
+
settingsCache.delete(join(root, SPEAKER_SHADOW_SETTINGS))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveSpeakerShadowSwitch(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): { enabled: boolean; source: SpeakerShadowSwitchSource } {
|
|
107
|
+
const pinned = speakerShadowEnvSwitch(env)
|
|
108
|
+
if (pinned !== null) return { enabled: pinned, source: 'env' }
|
|
109
|
+
const stored = readSpeakerShadowSetting(root)
|
|
110
|
+
if (stored !== null) return { enabled: stored, source: 'setting' }
|
|
111
|
+
return { enabled: false, source: 'default' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function speakerShadowEnabled(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): boolean {
|
|
115
|
+
return resolveSpeakerShadowSwitch(env, root).enabled
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function intEnv(env: NodeJS.ProcessEnv, key: string, fallback: number, min: number, max: number): number {
|
|
119
|
+
const raw = Number(env[key])
|
|
120
|
+
return Number.isFinite(raw) && raw > 0 ? Math.min(max, Math.max(min, Math.floor(raw))) : fallback
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SpeakerShadowLimits { dailyRuns: number; timeoutMs: number; rssBytes: number }
|
|
124
|
+
export function speakerShadowLimits(env: NodeJS.ProcessEnv = process.env): SpeakerShadowLimits {
|
|
125
|
+
return {
|
|
126
|
+
dailyRuns: intEnv(env, 'COS_SPEAKER_SHADOW_DAILY_RUNS', 12, 1, 50),
|
|
127
|
+
timeoutMs: intEnv(env, 'COS_SPEAKER_SHADOW_TIMEOUT_MS', SPEAKER_SHADOW_MAX_TIMEOUT_MS, 10_000, SPEAKER_SHADOW_MAX_TIMEOUT_MS),
|
|
128
|
+
rssBytes: intEnv(env, 'COS_SPEAKER_SHADOW_RSS_MB', 3072, 256, 8192) * 1024 * 1024,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Records ──────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export type SpeakerShadowStatus = 'proposed' | 'skipped' | 'failed'
|
|
135
|
+
export type SpeakerShadowRunnerReason =
|
|
136
|
+
| 'stale' | 'daily_cap' | 'cli_missing' | 'models_missing' | 'audio_missing'
|
|
137
|
+
| 'timeout' | 'rss_cap' | 'worker_crashed' | 'worker_spawn_failed'
|
|
138
|
+
|
|
139
|
+
export interface SpeakerShadowRecord {
|
|
140
|
+
schema: 1
|
|
141
|
+
kind: 'speaker_shadow'
|
|
142
|
+
/** Always true: nothing in this record was applied anywhere. */
|
|
143
|
+
logOnly: true
|
|
144
|
+
recordedAt: string
|
|
145
|
+
serverVersion: string
|
|
146
|
+
sessionId: string
|
|
147
|
+
enqueuedAt: string
|
|
148
|
+
status: SpeakerShadowStatus
|
|
149
|
+
reason: string | null
|
|
150
|
+
detail: string | null
|
|
151
|
+
gates: { majority: number; minCoveredFraction: number; floorSeconds: number; outlier: string; anchorRatioPostHocColumnOnly: number }
|
|
152
|
+
facts?: ShadowRunFacts
|
|
153
|
+
proposals?: ShadowProposals
|
|
154
|
+
resources?: { wallMs: number; peakRssBytes: number | null; cliTrust: string | null }
|
|
155
|
+
/** The readable report, relative to <data>/speaker-shadow/ (reports/<day>/<title>__<session>.md). */
|
|
156
|
+
report?: string | null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const GATES_RECORD = { majority: SHADOW_GATES.majority, minCoveredFraction: SHADOW_GATES.minCoveredFraction, floorSeconds: SHADOW_GATES.floorSeconds, outlier: 'leave-fragment-out cluster p95', anchorRatioPostHocColumnOnly: SHADOW_GATES.anchorRatioPostHoc }
|
|
160
|
+
|
|
161
|
+
/** Append one record: the whole file is rewritten to a temp file, fsynced and renamed, so a reader never sees half a line. */
|
|
162
|
+
export async function appendSpeakerShadowRecord(root: string, record: SpeakerShadowRecord): Promise<void> {
|
|
163
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
164
|
+
const path = join(root, SPEAKER_SHADOW_RECORDS)
|
|
165
|
+
let existing = ''
|
|
166
|
+
try { existing = await readFile(path, 'utf8') } catch { /* first record */ }
|
|
167
|
+
if (existing.length >= SPEAKER_SHADOW_RECORDS_ROTATE_BYTES) {
|
|
168
|
+
await rename(path, join(root, `proposals-${new Date(Date.parse(record.recordedAt)).toISOString().replace(/[:.]/g, '-')}.jsonl`))
|
|
169
|
+
existing = ''
|
|
170
|
+
}
|
|
171
|
+
if (existing && !existing.endsWith('\n')) existing += '\n'
|
|
172
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
173
|
+
const handle = await open(tmp, 'wx', 0o600)
|
|
174
|
+
try {
|
|
175
|
+
await handle.writeFile(`${existing}${JSON.stringify(record)}\n`)
|
|
176
|
+
await handle.sync()
|
|
177
|
+
} finally { await handle.close() }
|
|
178
|
+
try { await rename(tmp, path) } catch (error) { await unlink(tmp).catch(() => {}); throw error }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Newest first. Malformed lines are skipped, never fatal. */
|
|
182
|
+
export async function readSpeakerShadowRecords(root: string, options: { limit?: number; sessionId?: string } = {}): Promise<SpeakerShadowRecord[]> {
|
|
183
|
+
const limit = Math.max(1, Math.min(100, options.limit ?? 10))
|
|
184
|
+
let names: string[] = []
|
|
185
|
+
try { names = (await readdir(root)).filter(n => n === SPEAKER_SHADOW_RECORDS || /^proposals-[0-9TZ-]+\.jsonl$/.test(n)) } catch { return [] }
|
|
186
|
+
names.sort((a, b) => a === SPEAKER_SHADOW_RECORDS ? -1 : b === SPEAKER_SHADOW_RECORDS ? 1 : b.localeCompare(a))
|
|
187
|
+
const out: SpeakerShadowRecord[] = []
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
let text = ''
|
|
190
|
+
try { text = await readFile(join(root, name), 'utf8') } catch { continue }
|
|
191
|
+
const lines = text.split('\n').filter(Boolean).reverse()
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
let record: SpeakerShadowRecord
|
|
194
|
+
try { record = JSON.parse(line) } catch { continue }
|
|
195
|
+
if (record?.kind !== 'speaker_shadow') continue
|
|
196
|
+
if (options.sessionId && record.sessionId !== options.sessionId) continue
|
|
197
|
+
out.push(record)
|
|
198
|
+
if (out.length >= limit) return out
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
interface QueueItem { sessionId: string; sidecarPath: string; labelsHash: string | null; enqueuedAt: number }
|
|
207
|
+
interface ShadowState {
|
|
208
|
+
schema: 1
|
|
209
|
+
day: string
|
|
210
|
+
runsToday: number
|
|
211
|
+
consecutiveFailures: number
|
|
212
|
+
breakerUntil: number | null
|
|
213
|
+
lastRun: { at: number; sessionId: string; status: SpeakerShadowStatus; reason: string | null } | null
|
|
214
|
+
lastError: { at: number; reason: string; detail: string } | null
|
|
215
|
+
done: string[]
|
|
216
|
+
queue: QueueItem[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const emptyState = (day: string): ShadowState => ({ schema: 1, day, runsToday: 0, consecutiveFailures: 0, breakerUntil: null, lastRun: null, lastError: null, done: [], queue: [] })
|
|
220
|
+
|
|
221
|
+
function localDay(ms: number): string {
|
|
222
|
+
const d = new Date(ms)
|
|
223
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Runner ───────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export interface SpeakerShadowCli { path: string; sha256: string; trust: string | null }
|
|
229
|
+
|
|
230
|
+
export interface SpeakerShadowDeps {
|
|
231
|
+
env: () => NodeJS.ProcessEnv
|
|
232
|
+
root: () => string
|
|
233
|
+
now: () => number
|
|
234
|
+
/** True while a recording, save, finalization or one-shot transcription is live. Null = not wired: never run. */
|
|
235
|
+
captureActive: (() => boolean) | null
|
|
236
|
+
/** False during a maintenance drain. */
|
|
237
|
+
admissionsOpen: () => boolean
|
|
238
|
+
resolveCli: () => Promise<SpeakerShadowCli | null>
|
|
239
|
+
audioDirFor: (sessionId: string) => string
|
|
240
|
+
modelsDir: () => string
|
|
241
|
+
homeDir: () => string
|
|
242
|
+
sandboxExec: string
|
|
243
|
+
tmpRoot: string
|
|
244
|
+
/** Starts the worker child. Must start it as its own process group leader. */
|
|
245
|
+
spawnWorker: () => ChildProcess
|
|
246
|
+
/** Resident bytes of the process group led by pid, or null when it cannot be read. */
|
|
247
|
+
sampleRss: (pgid: number) => Promise<number | null>
|
|
248
|
+
killGroup: (pgid: number) => void
|
|
249
|
+
log: (line: string) => void
|
|
250
|
+
setTimer: (fn: () => void, ms: number) => { unref?: () => void }
|
|
251
|
+
/** The cheap scheduler-side model look (sizes only); the worker hashes every file. */
|
|
252
|
+
modelsPresent: (dir: string) => boolean
|
|
253
|
+
limits: () => SpeakerShadowLimits
|
|
254
|
+
/** Cheap, synchronous: is the CLI the runner would use installed, and how is it trusted. */
|
|
255
|
+
cliReadiness: () => { installed: boolean; trust: string | null }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface SpeakerShadowReadiness {
|
|
259
|
+
/** Every pinned model file is present with its size (the worker hashes them before each run). */
|
|
260
|
+
modelsPresent: boolean
|
|
261
|
+
/** The Parakeet install's fluidaudiocli is installed, and how it was trusted. */
|
|
262
|
+
cliInstalled: boolean
|
|
263
|
+
cliTrust: string | null
|
|
264
|
+
sandboxExec: boolean
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface SpeakerShadowSnapshot {
|
|
268
|
+
enabled: boolean
|
|
269
|
+
/** Where the switch came from: env pins it, the saved per-Mac setting, or the default (off). */
|
|
270
|
+
source: SpeakerShadowSwitchSource
|
|
271
|
+
state: 'off' | 'idle' | 'queued' | 'running' | 'deferred' | 'breaker_open' | 'no_live_probe'
|
|
272
|
+
queued: number
|
|
273
|
+
runsToday: number
|
|
274
|
+
dailyCap: number
|
|
275
|
+
breaker: { open: boolean; failures: number; until: string | null }
|
|
276
|
+
lastRun: { at: string; status: SpeakerShadowStatus; reason: string | null } | null
|
|
277
|
+
lastError: { at: string; reason: string } | null
|
|
278
|
+
lastErrorDetail?: string | null
|
|
279
|
+
readiness?: SpeakerShadowReadiness
|
|
280
|
+
deferredReason: string | null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const WORKER_PATH = fileURLToPath(new URL('./speaker-shadow-worker.ts', import.meta.url))
|
|
284
|
+
|
|
285
|
+
function defaultSpawnWorker(): ChildProcess {
|
|
286
|
+
const tsx = createRequire(import.meta.url).resolve('tsx/esm')
|
|
287
|
+
return spawn(process.execPath, ['--import', pathToFileURL(tsx).href, WORKER_PATH], {
|
|
288
|
+
detached: true,
|
|
289
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
290
|
+
// The worker needs the data dir to resolve nothing at all (paths arrive in the job), but
|
|
291
|
+
// importing server modules creates DATA_DIR, so point it at the same one.
|
|
292
|
+
env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, COS_DATA_DIR: process.env.COS_DATA_DIR, LANG: process.env.LANG },
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function defaultSampleRss(pgid: number): Promise<number | null> {
|
|
297
|
+
return new Promise(resolve => {
|
|
298
|
+
execFile('/bin/ps', ['-axo', 'pgid=,rss='], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
299
|
+
if (error) { resolve(null); return }
|
|
300
|
+
let kb = 0
|
|
301
|
+
for (const line of stdout.split('\n')) {
|
|
302
|
+
const [g, rss] = line.trim().split(/\s+/).map(Number)
|
|
303
|
+
if (g === pgid && Number.isFinite(rss)) kb += rss
|
|
304
|
+
}
|
|
305
|
+
resolve(kb * 1024)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function sha256File(path: string): Promise<string> {
|
|
311
|
+
const hash = createHash('sha256')
|
|
312
|
+
await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
|
|
313
|
+
return hash.digest('hex')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The server's own fluidaudiocli: the one the Parakeet install placed and verified. */
|
|
317
|
+
async function defaultResolveCli(): Promise<SpeakerShadowCli | null> {
|
|
318
|
+
try {
|
|
319
|
+
if (!parakeetInstalled().installed) return null
|
|
320
|
+
const path = parakeetCliPath()
|
|
321
|
+
if (!(await installedCliIntact(path))) return null
|
|
322
|
+
return { path, sha256: await sha256File(path), trust: installedCliTrust() }
|
|
323
|
+
} catch { return null }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function defaultSpeakerShadowDeps(): SpeakerShadowDeps {
|
|
327
|
+
return {
|
|
328
|
+
env: () => process.env,
|
|
329
|
+
root: speakerShadowRoot,
|
|
330
|
+
now: Date.now,
|
|
331
|
+
captureActive: null,
|
|
332
|
+
admissionsOpen: () => true,
|
|
333
|
+
resolveCli: defaultResolveCli,
|
|
334
|
+
audioDirFor: sessionId => join(speakerShadowRoot(), '..', 'meeting-audio', sessionId.replace(/:/g, '_')),
|
|
335
|
+
modelsDir: () => speakerShadowModelsDir(process.env),
|
|
336
|
+
homeDir: speakerShadowHome,
|
|
337
|
+
sandboxExec: SANDBOX_EXEC,
|
|
338
|
+
tmpRoot: tmpdir(),
|
|
339
|
+
spawnWorker: defaultSpawnWorker,
|
|
340
|
+
sampleRss: defaultSampleRss,
|
|
341
|
+
killGroup: pgid => { try { process.kill(-pgid, 'SIGKILL') } catch { /* gone */ } },
|
|
342
|
+
log: line => console.log(`[speaker-shadow] ${line}`),
|
|
343
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
344
|
+
modelsPresent: shadowModelsPresent,
|
|
345
|
+
limits: () => speakerShadowLimits(process.env),
|
|
346
|
+
cliReadiness: () => { try { return { installed: parakeetInstalled().installed, trust: installedCliTrust() } } catch { return { installed: false, trust: null } } },
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
type Supervised = ShadowJobOutcome | { status: 'deferred'; reason: string } | { status: 'failed'; reason: SpeakerShadowRunnerReason; detail: string }
|
|
351
|
+
|
|
352
|
+
export class SpeakerShadowRunner {
|
|
353
|
+
private readonly deps: SpeakerShadowDeps
|
|
354
|
+
private running: { sessionId: string; pgid: number | null } | null = null
|
|
355
|
+
private pumping = false
|
|
356
|
+
private timer: { unref?: () => void } | null = null
|
|
357
|
+
private deferredReason: string | null = null
|
|
358
|
+
private exitHook = false
|
|
359
|
+
|
|
360
|
+
constructor(deps: Partial<SpeakerShadowDeps> = {}) {
|
|
361
|
+
this.deps = { ...defaultSpeakerShadowDeps(), ...deps }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
enabled(): boolean { return speakerShadowEnabled(this.deps.env(), this.deps.root()) }
|
|
365
|
+
|
|
366
|
+
switchState(): { enabled: boolean; source: SpeakerShadowSwitchSource } { return resolveSpeakerShadowSwitch(this.deps.env(), this.deps.root()) }
|
|
367
|
+
|
|
368
|
+
/** Production wiring: what counts as live meeting work. Until it is set, nothing runs. */
|
|
369
|
+
setCaptureProbe(probe: () => boolean, admissionsOpen?: () => boolean): void {
|
|
370
|
+
this.deps.captureActive = probe
|
|
371
|
+
if (admissionsOpen) this.deps.admissionsOpen = admissionsOpen
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private statePath(): string { return join(this.deps.root(), SPEAKER_SHADOW_STATE) }
|
|
375
|
+
|
|
376
|
+
private readState(): ShadowState {
|
|
377
|
+
const day = localDay(this.deps.now())
|
|
378
|
+
let state: ShadowState
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(readFileSyncSafe(this.statePath()) ?? '') as ShadowState
|
|
381
|
+
state = parsed?.schema === 1 && Array.isArray(parsed.queue) ? { ...emptyState(day), ...parsed, done: Array.isArray(parsed.done) ? parsed.done : [] } : emptyState(day)
|
|
382
|
+
} catch { state = emptyState(day) }
|
|
383
|
+
if (state.day !== day) state = { ...state, day, runsToday: 0 }
|
|
384
|
+
return state
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private writeState(state: ShadowState): void {
|
|
388
|
+
mkdirSync(this.deps.root(), { recursive: true, mode: 0o700 })
|
|
389
|
+
state.done = state.done.slice(-500)
|
|
390
|
+
durableAtomicWriteFileSync(this.statePath(), `${JSON.stringify(state, null, 1)}\n`, { mode: 0o600 })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A Mac recording finished finalizing. Never throws and never blocks the caller: the
|
|
395
|
+
* sidecar read and everything after it run in the background.
|
|
396
|
+
*/
|
|
397
|
+
notifyFinalized(input: { sessionId: string; sidecarPath: string }): void {
|
|
398
|
+
try {
|
|
399
|
+
if (!this.enabled() || !MAC_CAPTURE_ID.test(input.sessionId)) return
|
|
400
|
+
void this.enqueue(input).catch(error => this.deps.log(`enqueue failed for ${input.sessionId}: ${describe(error)}`))
|
|
401
|
+
} catch (error) {
|
|
402
|
+
this.deps.log(`notify failed: ${describe(error)}`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private async enqueue(input: { sessionId: string; sidecarPath: string }): Promise<void> {
|
|
407
|
+
let labelsHash: string | null = null
|
|
408
|
+
try { labelsHash = shadowLabelsHash(shadowEntries(JSON.parse(await readFile(input.sidecarPath, 'utf8')))) } catch { /* the worker reports the sidecar */ }
|
|
409
|
+
// Read, change and write with no await in between: the pump also rewrites this file.
|
|
410
|
+
const state = this.readState()
|
|
411
|
+
if (state.done.includes(input.sessionId) || state.queue.some(q => q.sessionId === input.sessionId)) return
|
|
412
|
+
state.queue.push({ sessionId: input.sessionId, sidecarPath: input.sidecarPath, labelsHash, enqueuedAt: this.deps.now() })
|
|
413
|
+
const dropped = state.queue.splice(0, Math.max(0, state.queue.length - MAX_QUEUE))
|
|
414
|
+
for (const item of dropped) state.done.push(item.sessionId)
|
|
415
|
+
this.writeState(state)
|
|
416
|
+
for (const item of dropped) await this.record(item, 'skipped', 'stale', 'the shadow queue was full')
|
|
417
|
+
this.deps.log(`queued ${input.sessionId}`)
|
|
418
|
+
void this.pump()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Boot: pick up meetings a previous process queued but never ran. */
|
|
422
|
+
resume(): void {
|
|
423
|
+
try {
|
|
424
|
+
if (!this.enabled() || !existsSync(this.statePath())) return
|
|
425
|
+
if (this.readState().queue.length) void this.pump()
|
|
426
|
+
} catch (error) { this.deps.log(`resume failed: ${describe(error)}`) }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private schedule(ms: number, reason: string): void {
|
|
430
|
+
if (this.deferredReason !== reason) this.deps.log(`deferred: ${reason}`)
|
|
431
|
+
this.deferredReason = reason
|
|
432
|
+
if (this.timer) return
|
|
433
|
+
const t = this.deps.setTimer(() => { this.timer = null; void this.pump() }, ms)
|
|
434
|
+
t.unref?.()
|
|
435
|
+
this.timer = t
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async record(item: QueueItem, status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}): Promise<void> {
|
|
439
|
+
const record: SpeakerShadowRecord = {
|
|
440
|
+
schema: 1, kind: 'speaker_shadow', logOnly: true,
|
|
441
|
+
recordedAt: new Date(this.deps.now()).toISOString(),
|
|
442
|
+
serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
|
|
443
|
+
sessionId: item.sessionId, enqueuedAt: new Date(item.enqueuedAt).toISOString(),
|
|
444
|
+
status, reason, detail, gates: GATES_RECORD, ...extra,
|
|
445
|
+
}
|
|
446
|
+
await appendSpeakerShadowRecord(this.deps.root(), record)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The readable page for one finished meeting, written atomically under reports/<day>/. Every
|
|
451
|
+
* finished meeting gets one: proposals, or a short page saying why there are none. A report
|
|
452
|
+
* that cannot be written is logged and never blocks the record.
|
|
453
|
+
*/
|
|
454
|
+
private async writeReport(item: QueueItem, status: SpeakerShadowStatus, reason: string | null, detail: string | null, report?: { title: string; markdown: string }): Promise<string | null> {
|
|
455
|
+
try {
|
|
456
|
+
const title = report?.title ?? await readShadowMeetingTitle(item.sidecarPath)
|
|
457
|
+
const markdown = report?.markdown ?? renderShadowSkipReport({ title, sessionId: item.sessionId, status: status === 'proposed' ? 'skipped' : status, reason, detail })
|
|
458
|
+
const rel = shadowReportRelativePath(title, item.sessionId, shadowMeetingDay(item.sidecarPath) ?? localDay(this.deps.now()))
|
|
459
|
+
const path = join(this.deps.root(), rel)
|
|
460
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
461
|
+
durableAtomicWriteFileSync(path, markdown, { mode: 0o600 })
|
|
462
|
+
return rel
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.deps.log(`report not written for ${item.sessionId}: ${describe(error)}`)
|
|
465
|
+
return null
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Runs queued meetings one at a time until the queue is empty or something says wait. */
|
|
470
|
+
async pump(): Promise<void> {
|
|
471
|
+
if (this.pumping || this.running) return
|
|
472
|
+
this.pumping = true
|
|
473
|
+
try {
|
|
474
|
+
for (;;) {
|
|
475
|
+
if (!this.enabled()) return
|
|
476
|
+
const state = this.readState()
|
|
477
|
+
const item = state.queue[0]
|
|
478
|
+
if (!item) { this.deferredReason = null; return }
|
|
479
|
+
const now = this.deps.now()
|
|
480
|
+
const finish = async (status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}, report?: { title: string; markdown: string }) => {
|
|
481
|
+
const reportPath = await this.writeReport(item, status, reason, detail, report)
|
|
482
|
+
await this.record(item, status, reason, detail, { ...extra, report: reportPath })
|
|
483
|
+
const next = this.readState()
|
|
484
|
+
next.queue = next.queue.filter(q => q.sessionId !== item.sessionId)
|
|
485
|
+
next.done.push(item.sessionId)
|
|
486
|
+
next.lastRun = { at: this.deps.now(), sessionId: item.sessionId, status, reason }
|
|
487
|
+
if (status === 'failed') {
|
|
488
|
+
next.consecutiveFailures += 1
|
|
489
|
+
next.lastError = { at: this.deps.now(), reason: reason ?? 'failed', detail: (detail ?? '').slice(0, 300) }
|
|
490
|
+
if (next.consecutiveFailures >= SPEAKER_SHADOW_BREAKER_FAILURES) {
|
|
491
|
+
next.breakerUntil = this.deps.now() + SPEAKER_SHADOW_BREAKER_PAUSE_MS
|
|
492
|
+
this.deps.log(`breaker open for 1 h after ${next.consecutiveFailures} failures (last: ${reason})`)
|
|
493
|
+
}
|
|
494
|
+
} else if (status === 'proposed') {
|
|
495
|
+
next.consecutiveFailures = 0
|
|
496
|
+
next.breakerUntil = null
|
|
497
|
+
}
|
|
498
|
+
this.writeState(next)
|
|
499
|
+
this.deps.log(`${status} ${item.sessionId}${reason ? ` (${reason})` : ''}${extra.proposals ? `: ${extra.proposals.productionVoices} voices, ${extra.proposals.acceptedMerges} of ${extra.proposals.proposedMerges} proposed merges pass` : ''}`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (now - item.enqueuedAt > QUEUE_MAX_AGE_MS) { await finish('skipped', 'stale', 'queued for more than 24 hours'); continue }
|
|
503
|
+
if (state.breakerUntil && now < state.breakerUntil) { this.schedule(state.breakerUntil - now, 'breaker_open'); return }
|
|
504
|
+
if (!this.deps.captureActive) { this.schedule(DEFER_RETRY_MS, 'no_live_probe'); return }
|
|
505
|
+
if (this.deps.captureActive() || !this.deps.admissionsOpen()) { this.schedule(DEFER_RETRY_MS, 'live_work'); return }
|
|
506
|
+
const limits = this.deps.limits()
|
|
507
|
+
if (state.runsToday >= limits.dailyRuns) { await finish('skipped', 'daily_cap', `${limits.dailyRuns} runs today`); continue }
|
|
508
|
+
const cli = await this.deps.resolveCli()
|
|
509
|
+
if (!cli) { await finish('skipped', 'cli_missing', 'no verified fluidaudiocli from the Parakeet install'); continue }
|
|
510
|
+
const modelsDir = this.deps.modelsDir()
|
|
511
|
+
if (!this.deps.modelsPresent(modelsDir)) { await finish('skipped', 'models_missing', 'the pinned speaker-diarization models are not on this Mac (the server never downloads them)'); continue }
|
|
512
|
+
const audioDir = this.deps.audioDirFor(item.sessionId)
|
|
513
|
+
if (!existsSync(audioDir)) { await finish('skipped', 'audio_missing', 'no retained chunk audio for this meeting'); continue }
|
|
514
|
+
|
|
515
|
+
// Fresh read: the awaits above let an enqueue rewrite the file since `state` was read.
|
|
516
|
+
const counted = this.readState()
|
|
517
|
+
counted.runsToday += 1
|
|
518
|
+
this.writeState(counted)
|
|
519
|
+
this.deferredReason = null
|
|
520
|
+
const job: ShadowJob = {
|
|
521
|
+
sessionId: item.sessionId, sidecarPath: item.sidecarPath, audioDir, expectedLabelsHash: item.labelsHash,
|
|
522
|
+
cliPath: cli.path, cliSha256: cli.sha256, sandboxExec: this.deps.sandboxExec, modelsDir,
|
|
523
|
+
homeDir: this.deps.homeDir(), tmpRoot: this.deps.tmpRoot, cliTimeoutMs: Math.max(5_000, limits.timeoutMs - 5_000),
|
|
524
|
+
}
|
|
525
|
+
this.deps.log(`start ${item.sessionId}`)
|
|
526
|
+
const started = this.deps.now()
|
|
527
|
+
const { outcome, peakRss } = await this.supervise(job, limits)
|
|
528
|
+
const resources = { wallMs: this.deps.now() - started, peakRssBytes: peakRss, cliTrust: cli.trust }
|
|
529
|
+
if (outcome.status === 'deferred') {
|
|
530
|
+
// Not a failure and not a run: the meeting stays at the head of the queue.
|
|
531
|
+
const back = this.readState()
|
|
532
|
+
back.runsToday = Math.max(0, back.runsToday - 1)
|
|
533
|
+
this.writeState(back)
|
|
534
|
+
this.deps.log(`stopped ${item.sessionId}: ${outcome.reason}; it runs again when the live work ends`)
|
|
535
|
+
this.schedule(DEFER_RETRY_MS, outcome.reason)
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
if (outcome.status === 'proposed') {
|
|
539
|
+
const f = outcome.facts
|
|
540
|
+
const markdown = `${outcome.report.markdown}\n${renderShadowRunFacts({ rebuildMs: f.timing.rebuildMs, nativeMs: f.timing.nativeMs, wallMs: resources.wallMs, peakRssBytes: resources.peakRssBytes, osTempDirsLeft: f.osTempDirsLeft, sidecarPath: item.sidecarPath, pcmSHA256: f.timelinePCMSHA256, cliTrust: cli.trust })}`
|
|
541
|
+
await finish('proposed', null, null, { facts: outcome.facts, proposals: outcome.proposals, resources }, { title: outcome.report.title, markdown })
|
|
542
|
+
}
|
|
543
|
+
else await finish(outcome.status, outcome.reason, outcome.detail, { resources })
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
this.deps.log(`pump failed: ${describe(error)}`)
|
|
547
|
+
} finally {
|
|
548
|
+
this.pumping = false
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private supervise(job: ShadowJob, limits: SpeakerShadowLimits): Promise<{ outcome: Supervised; peakRss: number | null }> {
|
|
553
|
+
return new Promise(resolvePromise => {
|
|
554
|
+
let child: ChildProcess
|
|
555
|
+
try { child = this.deps.spawnWorker() } catch (error) {
|
|
556
|
+
resolvePromise({ outcome: { status: 'failed', reason: 'worker_spawn_failed', detail: describe(error) }, peakRss: null })
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
const pgid = child.pid ?? null
|
|
560
|
+
this.running = { sessionId: job.sessionId, pgid }
|
|
561
|
+
this.installExitHook()
|
|
562
|
+
if (pgid) { try { setPriority(pgid, 10) } catch { /* runs at normal priority */ } }
|
|
563
|
+
const started = this.deps.now()
|
|
564
|
+
let stdout = '', stderr = '', verdict: Supervised | null = null, peak: number | null = null, sampling = false, settled = false
|
|
565
|
+
const stop = (outcome: Supervised) => { if (!verdict) verdict = outcome; if (pgid) this.deps.killGroup(pgid) }
|
|
566
|
+
const tick = setInterval(() => {
|
|
567
|
+
if (verdict) return
|
|
568
|
+
if (this.deps.now() - started > limits.timeoutMs) { stop({ status: 'failed', reason: 'timeout', detail: `no result within ${limits.timeoutMs} ms` }); return }
|
|
569
|
+
if (!this.enabled()) { stop({ status: 'deferred', reason: 'switched_off' }); return }
|
|
570
|
+
if (this.deps.captureActive?.() || !this.deps.admissionsOpen()) { stop({ status: 'deferred', reason: 'capture_live' }); return }
|
|
571
|
+
if (!pgid || sampling) return
|
|
572
|
+
sampling = true
|
|
573
|
+
void this.deps.sampleRss(pgid).then(bytes => {
|
|
574
|
+
sampling = false
|
|
575
|
+
if (bytes === null || verdict) return
|
|
576
|
+
peak = Math.max(peak ?? 0, bytes)
|
|
577
|
+
if (bytes > limits.rssBytes) stop({ status: 'failed', reason: 'rss_cap', detail: `the run used ${Math.round(bytes / 1048576)} MB, over the ${Math.round(limits.rssBytes / 1048576)} MB cap` })
|
|
578
|
+
})
|
|
579
|
+
}, SAMPLE_MS)
|
|
580
|
+
tick.unref?.()
|
|
581
|
+
const done = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
582
|
+
if (settled) return
|
|
583
|
+
settled = true
|
|
584
|
+
clearInterval(tick)
|
|
585
|
+
this.running = null
|
|
586
|
+
if (verdict) { resolvePromise({ outcome: verdict, peakRss: peak }); return }
|
|
587
|
+
const line = stdout.trim().split('\n').pop() ?? ''
|
|
588
|
+
try {
|
|
589
|
+
const outcome = JSON.parse(line) as ShadowJobOutcome
|
|
590
|
+
if (outcome && (outcome.status === 'proposed' || outcome.status === 'skipped' || outcome.status === 'failed')) { resolvePromise({ outcome, peakRss: peak }); return }
|
|
591
|
+
} catch { /* below */ }
|
|
592
|
+
resolvePromise({ outcome: { status: 'failed', reason: 'worker_crashed', detail: `worker exit ${code ?? signal}: ${stderr.trim().split('\n').slice(-2).join(' | ').slice(0, 200)}` }, peakRss: peak })
|
|
593
|
+
}
|
|
594
|
+
child.stdout?.on('data', (d: Buffer) => { if (stdout.length < MAX_STDOUT) stdout += d.toString(); else stop({ status: 'failed', reason: 'worker_crashed', detail: 'worker output over 8 MB' }) })
|
|
595
|
+
child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 16_384) stderr += d.toString() })
|
|
596
|
+
child.on('error', error => { stop({ status: 'failed', reason: 'worker_spawn_failed', detail: error.message }); done(null, null) })
|
|
597
|
+
child.on('close', done)
|
|
598
|
+
child.stdin?.on('error', () => { /* a crashed worker closes stdin; close reports it */ })
|
|
599
|
+
child.stdin?.end(JSON.stringify(job))
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private installExitHook(): void {
|
|
604
|
+
if (this.exitHook) return
|
|
605
|
+
this.exitHook = true
|
|
606
|
+
process.once('exit', () => { if (this.running?.pgid) this.deps.killGroup(this.running.pgid) })
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
snapshot(options: { detail?: boolean } = {}): SpeakerShadowSnapshot {
|
|
610
|
+
const { enabled, source } = this.switchState()
|
|
611
|
+
const limits = this.deps.limits()
|
|
612
|
+
let state: ShadowState | null = null
|
|
613
|
+
try { if (existsSync(this.statePath())) state = this.readState() } catch { state = null }
|
|
614
|
+
const now = this.deps.now()
|
|
615
|
+
const breakerOpen = !!state?.breakerUntil && now < state.breakerUntil
|
|
616
|
+
const phase: SpeakerShadowSnapshot['state'] = !enabled ? 'off'
|
|
617
|
+
: this.running ? 'running'
|
|
618
|
+
: breakerOpen ? 'breaker_open'
|
|
619
|
+
: !this.deps.captureActive && state?.queue.length ? 'no_live_probe'
|
|
620
|
+
: this.deferredReason && state?.queue.length ? 'deferred'
|
|
621
|
+
: state?.queue.length ? 'queued' : 'idle'
|
|
622
|
+
const readiness = (): SpeakerShadowReadiness => {
|
|
623
|
+
const cli = this.deps.cliReadiness()
|
|
624
|
+
let modelsPresent = false
|
|
625
|
+
try { modelsPresent = this.deps.modelsPresent(this.deps.modelsDir()) } catch { modelsPresent = false }
|
|
626
|
+
return { modelsPresent, cliInstalled: cli.installed, cliTrust: cli.trust, sandboxExec: existsSync(this.deps.sandboxExec) }
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
enabled, source, state: phase,
|
|
630
|
+
queued: state?.queue.length ?? 0,
|
|
631
|
+
runsToday: state && state.day === localDay(now) ? state.runsToday : 0,
|
|
632
|
+
dailyCap: limits.dailyRuns,
|
|
633
|
+
breaker: { open: breakerOpen, failures: state?.consecutiveFailures ?? 0, until: breakerOpen ? new Date(state!.breakerUntil!).toISOString() : null },
|
|
634
|
+
lastRun: state?.lastRun ? { at: new Date(state.lastRun.at).toISOString(), status: state.lastRun.status, reason: state.lastRun.reason } : null,
|
|
635
|
+
lastError: state?.lastError ? { at: new Date(state.lastError.at).toISOString(), reason: state.lastError.reason } : null,
|
|
636
|
+
...(options.detail ? { lastErrorDetail: state?.lastError?.detail ?? null, readiness: readiness() } : {}),
|
|
637
|
+
deferredReason: this.deferredReason,
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** One synchronous read, only for the small state file. */
|
|
643
|
+
function readFileSyncSafe(path: string): string | null {
|
|
644
|
+
try { return readFileSync(path, 'utf8') } catch { return null }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function describe(error: unknown): string {
|
|
648
|
+
return error instanceof Error ? error.message : String(error)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Server start: temp directories a previous worker left (a SIGKILL skips its cleanup). */
|
|
652
|
+
export function sweepStaleSpeakerShadowTmp(root = tmpdir(), minAgeMs = 10 * 60_000, now = Date.now()): number {
|
|
653
|
+
let removed = 0
|
|
654
|
+
try {
|
|
655
|
+
for (const name of readdirSync(root)) {
|
|
656
|
+
if (!name.startsWith(SHADOW_TMP_PREFIX)) continue
|
|
657
|
+
const path = join(root, name)
|
|
658
|
+
try { if (now - statSync(path).mtimeMs >= minAgeMs) { rmSync(path, { recursive: true, force: true }); removed++ } } catch { /* raced */ }
|
|
659
|
+
}
|
|
660
|
+
} catch { /* no tmp root */ }
|
|
661
|
+
return removed
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Process-wide runner ──────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
let shared: SpeakerShadowRunner | null = null
|
|
667
|
+
export function getSpeakerShadowRunner(): SpeakerShadowRunner {
|
|
668
|
+
shared ??= new SpeakerShadowRunner()
|
|
669
|
+
return shared
|
|
670
|
+
}
|
|
671
|
+
export function resetSpeakerShadowRunnerForTests(runner: SpeakerShadowRunner | null = null): void {
|
|
672
|
+
shared = runner
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Leases that mean live meeting or dictation work. The merge engine's CAPTURE_LEASE_KINDS
|
|
677
|
+
* (meeting-actions.ts) plus dictation's draft writes, warms and finalize: dictation runs
|
|
678
|
+
* Parakeet on the same Neural Engine this CLI uses. Pinned against CAPTURE_LEASE_KINDS by test.
|
|
679
|
+
*/
|
|
680
|
+
export const SPEAKER_SHADOW_LIVE_LEASES = [
|
|
681
|
+
'recording_chunk', 'meeting_save', 'meeting_batch_finalization', 'orphan_recovery', 'one_shot_transcription',
|
|
682
|
+
'prompt_draft_write', 'prompt_draft_warm', 'prompt_draft_finalize',
|
|
683
|
+
] as const
|
|
684
|
+
|
|
685
|
+
export function speakerShadowLeaseActive(activeByKind: () => Record<string, number> = () => maintenanceLifecycle.snapshot().activeByKind as Record<string, number>): boolean {
|
|
686
|
+
const byKind = activeByKind()
|
|
687
|
+
return SPEAKER_SHADOW_LIVE_LEASES.some(kind => (byKind[kind] ?? 0) > 0)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Server boot: what counts as live work, then the temp sweep and any queue a previous process left. */
|
|
691
|
+
export function configureSpeakerShadow(probe: { captureActive: () => boolean; admissionsOpen: () => boolean }): void {
|
|
692
|
+
try {
|
|
693
|
+
const runner = getSpeakerShadowRunner()
|
|
694
|
+
runner.setCaptureProbe(probe.captureActive, probe.admissionsOpen)
|
|
695
|
+
if (!runner.enabled()) return
|
|
696
|
+
const swept = sweepStaleSpeakerShadowTmp()
|
|
697
|
+
if (swept) console.log(`[speaker-shadow] removed ${swept} stale temp dir(s)`)
|
|
698
|
+
console.log('[speaker-shadow] enabled (log only)')
|
|
699
|
+
runner.resume()
|
|
700
|
+
} catch (error) {
|
|
701
|
+
console.warn('[speaker-shadow] configure failed:', describe(error))
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** The finalization path's call. Never throws. */
|
|
706
|
+
export function notifySpeakerShadowFinalized(input: { sessionId: string; sidecarPath: string }): void {
|
|
707
|
+
try { getSpeakerShadowRunner().notifyFinalized(input) } catch (error) { console.warn('[speaker-shadow] unavailable:', describe(error)) }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* `/api/health`: `speaker_shadow` only while shadow mode is on. Absent, never null, when off, so
|
|
712
|
+
* the public health shape is unchanged by default (pinned by health-6890-parity.test.ts).
|
|
713
|
+
*/
|
|
714
|
+
export function speakerShadowHealthFields(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): { speaker_shadow?: Omit<SpeakerShadowSnapshot, 'lastErrorDetail' | 'readiness'> } {
|
|
715
|
+
if (!speakerShadowEnabled(env, root)) return {}
|
|
716
|
+
return { speaker_shadow: speakerShadowHealth() }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** For /api/health (codes and counts only, no session ids, paths or error text). */
|
|
720
|
+
export function speakerShadowHealth(): Omit<SpeakerShadowSnapshot, 'lastErrorDetail' | 'readiness'> {
|
|
721
|
+
try {
|
|
722
|
+
const { lastErrorDetail: _detail, readiness: _readiness, ...rest } = getSpeakerShadowRunner().snapshot()
|
|
723
|
+
return rest
|
|
724
|
+
} catch {
|
|
725
|
+
return { enabled: false, source: 'default', state: 'off', queued: 0, runsToday: 0, dailyCap: 0, breaker: { open: false, failures: 0, until: null }, lastRun: null, lastError: null, deferredReason: null }
|
|
726
|
+
}
|
|
727
|
+
}
|