@gotcos/glasses-server 6.12.7 → 6.14.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 +11 -0
- package/README.md +10 -0
- package/bin/cli.cjs +12 -0
- package/bin/managed-server.cjs +28 -0
- package/managed-runtime-contract.json +23 -0
- package/package.json +6 -3
- package/server/index.ts +106 -35
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/launch-dir.ts +13 -7
- package/server/lib/maintenance-lifecycle.ts +735 -0
- package/server/lib/managed-runtime.ts +44 -0
- package/server/lib/openai-tts-budget.ts +142 -0
- package/server/lib/prompt-edit.ts +224 -0
- package/server/lib/query-job-coordinator.ts +36 -4
- package/server/lib/query-job-runtime.ts +38 -26
- package/server/lib/recovery-activity.ts +168 -0
- package/server/lib/speaker-trainer.ts +532 -0
- package/server/lib/tts-cache.ts +596 -0
- package/server/routes/bookmarks.ts +59 -0
- package/server/routes/glossary.ts +153 -0
- package/server/routes/handoffs.ts +101 -0
- package/server/routes/health.ts +15 -12
- package/server/routes/maintenance.ts +160 -0
- package/server/routes/meeting.ts +25 -8
- package/server/routes/openai-compat.ts +82 -36
- package/server/routes/prompt-drafts.ts +51 -8
- package/server/routes/prompt-edit.ts +33 -0
- package/server/routes/query.ts +52 -24
- package/server/routes/recovery.ts +76 -0
- package/server/routes/transcribe-stream.ts +49 -3
- package/server/routes/transcribe.ts +14 -0
- package/server/routes/tts.ts +709 -0
- package/server/routes/voice.ts +317 -0
- package/shared/handoff-intent.ts +90 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Transcription glossary — runtime-editable positive vocabulary, exact
|
|
2
|
+
// corrections, and negative cleanup rules. Persists into .cos-profile.json and
|
|
3
|
+
// busts the profile + decoder caches so edits take effect WITHOUT a server
|
|
4
|
+
// restart. Auto token-protected by the /api middleware (server/index.ts:124).
|
|
5
|
+
//
|
|
6
|
+
// GET /api/transcription-glossary → { vocabulary, corrections, negative_rules }
|
|
7
|
+
// PUT /api/transcription-glossary → same shape; PARTIAL (any omitted field is
|
|
8
|
+
// left unchanged). whisper_corrections is stored as a JSON STRING in the
|
|
9
|
+
// profile (legacy decoder contract), so the route encodes/decodes it here.
|
|
10
|
+
|
|
11
|
+
import { Router } from 'express'
|
|
12
|
+
import { errMsg } from '../lib/utils.js'
|
|
13
|
+
import {
|
|
14
|
+
getVocabulary,
|
|
15
|
+
getNegativeRules,
|
|
16
|
+
loadProfileField,
|
|
17
|
+
updateProfileFields,
|
|
18
|
+
} from '../lib/profile.js'
|
|
19
|
+
import { resetDecoderCaches } from '../lib/whisper-local.js'
|
|
20
|
+
import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
|
|
21
|
+
import { validateNegativeRule } from '../lib/hallucination-filter.js'
|
|
22
|
+
|
|
23
|
+
export const glossaryRouter = Router()
|
|
24
|
+
|
|
25
|
+
const MAX_TERMS = 500
|
|
26
|
+
const MAX_TERM_LEN = 100
|
|
27
|
+
const MAX_RULES = 500
|
|
28
|
+
|
|
29
|
+
// Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
|
|
30
|
+
// there induce ".com"/handle hallucinations on quiet audio — reject them.
|
|
31
|
+
function looksLikeUrlEmailPath(s: string): boolean {
|
|
32
|
+
return /(?:https?:\/\/|www\.)/i.test(s)
|
|
33
|
+
|| /@[\w.-]+\.\w/.test(s)
|
|
34
|
+
|| /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
|
|
35
|
+
|| /[/\\]/.test(s)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readCorrections(): Record<string, string> {
|
|
39
|
+
try {
|
|
40
|
+
const raw = loadProfileField('whisper_corrections', '')
|
|
41
|
+
if (!raw) return {}
|
|
42
|
+
const parsed = JSON.parse(raw)
|
|
43
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
44
|
+
? parsed as Record<string, string>
|
|
45
|
+
: {}
|
|
46
|
+
} catch {
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function currentGlossary() {
|
|
52
|
+
return {
|
|
53
|
+
vocabulary: getVocabulary(),
|
|
54
|
+
corrections: readCorrections(),
|
|
55
|
+
negative_rules: getNegativeRules(),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Serialize PUTs so two writers can't clobber the read-modify-write. (The RMW in
|
|
60
|
+
// updateProfileFields is synchronous today, but this guards against future async
|
|
61
|
+
// drift and satisfies the write-lock contract.)
|
|
62
|
+
let putChain: Promise<unknown> = Promise.resolve()
|
|
63
|
+
|
|
64
|
+
glossaryRouter.get('/transcription-glossary', (_req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
res.json(currentGlossary())
|
|
67
|
+
} catch (err) {
|
|
68
|
+
res.status(500).json({ error: errMsg(err) })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
glossaryRouter.put('/transcription-glossary', async (req, res) => {
|
|
73
|
+
const body = req.body ?? {}
|
|
74
|
+
const patch: Record<string, unknown> = {}
|
|
75
|
+
|
|
76
|
+
// ── vocabulary (positive spellings → decoder prompt + fuzzy targets) ──
|
|
77
|
+
if (body.vocabulary !== undefined) {
|
|
78
|
+
if (!Array.isArray(body.vocabulary)) {
|
|
79
|
+
return res.status(400).json({ error: 'vocabulary must be an array of strings' })
|
|
80
|
+
}
|
|
81
|
+
if (body.vocabulary.length > MAX_TERMS) {
|
|
82
|
+
return res.status(400).json({ error: `too many vocabulary terms (max ${MAX_TERMS})` })
|
|
83
|
+
}
|
|
84
|
+
const vocab: string[] = []
|
|
85
|
+
for (const raw of body.vocabulary) {
|
|
86
|
+
if (typeof raw !== 'string') return res.status(400).json({ error: 'vocabulary entries must be strings' })
|
|
87
|
+
const term = raw.trim()
|
|
88
|
+
if (!term) continue
|
|
89
|
+
if (term.length > MAX_TERM_LEN) return res.status(400).json({ error: `vocabulary term too long: "${term.slice(0, 40)}…"` })
|
|
90
|
+
if (looksLikeUrlEmailPath(term)) {
|
|
91
|
+
return res.status(400).json({ error: `vocabulary cannot contain URLs/emails/paths: "${term}" — they induce hallucinations in the decoder prompt` })
|
|
92
|
+
}
|
|
93
|
+
vocab.push(term)
|
|
94
|
+
}
|
|
95
|
+
patch.vocabulary = vocab
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── corrections (bad → good) — persisted as a JSON STRING ──
|
|
99
|
+
if (body.corrections !== undefined) {
|
|
100
|
+
if (typeof body.corrections !== 'object' || body.corrections === null || Array.isArray(body.corrections)) {
|
|
101
|
+
return res.status(400).json({ error: 'corrections must be an object { "bad": "good" }' })
|
|
102
|
+
}
|
|
103
|
+
const entries = Object.entries(body.corrections as Record<string, unknown>)
|
|
104
|
+
if (entries.length > MAX_TERMS) return res.status(400).json({ error: `too many corrections (max ${MAX_TERMS})` })
|
|
105
|
+
const map: Record<string, string> = {}
|
|
106
|
+
for (const [bad, good] of entries) {
|
|
107
|
+
const b = bad.trim()
|
|
108
|
+
if (!b) continue
|
|
109
|
+
if (typeof good !== 'string') return res.status(400).json({ error: `correction "${bad}" must map to a string` })
|
|
110
|
+
if (b.length > MAX_TERM_LEN || good.length > MAX_TERM_LEN) {
|
|
111
|
+
return res.status(400).json({ error: `correction too long near "${b.slice(0, 40)}"` })
|
|
112
|
+
}
|
|
113
|
+
map[b] = good.trim()
|
|
114
|
+
}
|
|
115
|
+
patch.whisper_corrections = JSON.stringify(map)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── negative rules (whole/strip/replace/flag) ──
|
|
119
|
+
if (body.negative_rules !== undefined) {
|
|
120
|
+
if (!Array.isArray(body.negative_rules)) {
|
|
121
|
+
return res.status(400).json({ error: 'negative_rules must be an array of strings' })
|
|
122
|
+
}
|
|
123
|
+
if (body.negative_rules.length > MAX_RULES) {
|
|
124
|
+
return res.status(400).json({ error: `too many negative rules (max ${MAX_RULES})` })
|
|
125
|
+
}
|
|
126
|
+
const rules: string[] = []
|
|
127
|
+
for (const raw of body.negative_rules) {
|
|
128
|
+
if (typeof raw !== 'string') return res.status(400).json({ error: 'negative_rules entries must be strings' })
|
|
129
|
+
const v = validateNegativeRule(raw)
|
|
130
|
+
if (!v.ok) return res.status(400).json({ error: `invalid rule "${raw.slice(0, 60)}": ${v.error}` })
|
|
131
|
+
rules.push(raw)
|
|
132
|
+
}
|
|
133
|
+
patch.negative_rules = rules
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Object.keys(patch).length === 0) {
|
|
137
|
+
return res.status(400).json({ error: 'nothing to update (send vocabulary, corrections, and/or negative_rules)' })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// Cache-bust chain: updateProfileFields() writes atomically + clears the ROOT
|
|
142
|
+
// profileCache; resetDecoderCaches() clears the two derived decoder snapshots.
|
|
143
|
+
// Both are required for an edit to reach the decoder without a restart.
|
|
144
|
+
await (putChain = putChain.catch(() => {}).then(() => {
|
|
145
|
+
updateProfileFields(patch)
|
|
146
|
+
resetDecoderCaches()
|
|
147
|
+
resetVocabEchoCache() // vocab matcher in hallucination-filter is profile-derived too
|
|
148
|
+
}))
|
|
149
|
+
return res.json(currentGlossary())
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return res.status(500).json({ error: errMsg(err) })
|
|
152
|
+
}
|
|
153
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import {
|
|
3
|
+
buildHandoffPromptContext,
|
|
4
|
+
claimHandoff,
|
|
5
|
+
createHandoff,
|
|
6
|
+
getHandoff,
|
|
7
|
+
getLatestHandoff,
|
|
8
|
+
type HandoffCreateInput,
|
|
9
|
+
} from '../lib/handoff-store.js'
|
|
10
|
+
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
11
|
+
import { getCodexExecutionCwd, getCodexTrustMode } from '../lib/codex-run-ledger.js'
|
|
12
|
+
import { normalizeHandoffCode } from '../../shared/handoff-intent.js'
|
|
13
|
+
|
|
14
|
+
export const handoffsRouter = Router()
|
|
15
|
+
|
|
16
|
+
function runtimeExpiresAt(): string {
|
|
17
|
+
return new Date(Date.now() + 2 * 60 * 60_000).toISOString()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function enrichRuntime(body: Record<string, any>): HandoffCreateInput {
|
|
21
|
+
const input: HandoffCreateInput = { ...body }
|
|
22
|
+
const runtime = body.runtime && typeof body.runtime === 'object' ? { ...body.runtime } : {}
|
|
23
|
+
|
|
24
|
+
if (runtime.codex?.codexThreadId) {
|
|
25
|
+
runtime.codex = {
|
|
26
|
+
...runtime.codex,
|
|
27
|
+
cwd: runtime.codex.cwd ?? getCodexExecutionCwd(),
|
|
28
|
+
trustMode: runtime.codex.trustMode ?? getCodexTrustMode(),
|
|
29
|
+
expiresAt: runtime.codex.expiresAt ?? runtimeExpiresAt(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (runtime.claude?.cliSessionId) {
|
|
34
|
+
runtime.claude = {
|
|
35
|
+
...runtime.claude,
|
|
36
|
+
expiresAt: runtime.claude.expiresAt ?? runtimeExpiresAt(),
|
|
37
|
+
}
|
|
38
|
+
} else if (typeof body.sessionId === 'string') {
|
|
39
|
+
const cliSessionId = getAvailableCliSessionId(body.sessionId)
|
|
40
|
+
if (cliSessionId) {
|
|
41
|
+
runtime.claude = {
|
|
42
|
+
cliSessionId,
|
|
43
|
+
model: typeof body.model === 'string' ? body.model : undefined,
|
|
44
|
+
expiresAt: runtimeExpiresAt(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (runtime.codex || runtime.claude) input.runtime = runtime
|
|
50
|
+
return input
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
handoffsRouter.post('/handoffs', async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
|
56
|
+
const handoff = await createHandoff(enrichRuntime(body))
|
|
57
|
+
res.status(201).json({ handoff, context: buildHandoffPromptContext(handoff) })
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
res.status(500).json({ error: err?.message ?? 'handoff create failed' })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
handoffsRouter.get('/handoffs/latest', async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const handoff = await getLatestHandoff({
|
|
66
|
+
source: typeof req.query.source === 'string' ? req.query.source : undefined,
|
|
67
|
+
target: typeof req.query.target === 'string' ? req.query.target : undefined,
|
|
68
|
+
createdBy: typeof req.query.createdBy === 'string' ? req.query.createdBy : undefined,
|
|
69
|
+
deviceId: typeof req.query.deviceId === 'string' ? req.query.deviceId : undefined,
|
|
70
|
+
})
|
|
71
|
+
if (!handoff) return res.status(404).json({ error: 'handoff not found' })
|
|
72
|
+
res.json({ handoff, context: buildHandoffPromptContext(handoff) })
|
|
73
|
+
} catch (err: any) {
|
|
74
|
+
res.status(500).json({ error: err?.message ?? 'handoff lookup failed' })
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
handoffsRouter.get('/handoffs/:code', async (req, res) => {
|
|
79
|
+
const code = normalizeHandoffCode(req.params.code)
|
|
80
|
+
if (!code) return res.status(404).json({ error: 'handoff not found' })
|
|
81
|
+
try {
|
|
82
|
+
const handoff = await getHandoff(code)
|
|
83
|
+
if (!handoff) return res.status(404).json({ error: 'handoff not found' })
|
|
84
|
+
res.json({ handoff, context: buildHandoffPromptContext(handoff) })
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
res.status(500).json({ error: err?.message ?? 'handoff lookup failed' })
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
handoffsRouter.post('/handoffs/:code/claim', async (req, res) => {
|
|
91
|
+
const code = normalizeHandoffCode(req.params.code)
|
|
92
|
+
if (!code) return res.status(404).json({ error: 'handoff not found' })
|
|
93
|
+
try {
|
|
94
|
+
const claimedBy = typeof req.body?.claimedBy === 'string' ? req.body.claimedBy : 'unknown'
|
|
95
|
+
const handoff = await claimHandoff(code, claimedBy)
|
|
96
|
+
if (!handoff) return res.status(404).json({ error: 'handoff not found' })
|
|
97
|
+
res.json({ handoff, context: buildHandoffPromptContext(handoff) })
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
res.status(500).json({ error: err?.message ?? 'handoff claim failed' })
|
|
100
|
+
}
|
|
101
|
+
})
|
package/server/routes/health.ts
CHANGED
|
@@ -21,6 +21,9 @@ import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
|
21
21
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
22
22
|
import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
|
|
23
23
|
import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
|
|
24
|
+
import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
|
|
25
|
+
import { getServerGenerationId } from '../lib/managed-runtime.js'
|
|
26
|
+
import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
|
|
24
27
|
|
|
25
28
|
export const healthRouter = Router()
|
|
26
29
|
|
|
@@ -145,12 +148,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
145
148
|
const durableJobs = durableQueryJobStatus()
|
|
146
149
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
147
150
|
const transcription = getTranscriptionPolicySnapshot()
|
|
148
|
-
const recovery =
|
|
149
|
-
|
|
150
|
-
restartWhisper: false,
|
|
151
|
-
restartServer: false,
|
|
152
|
-
managed: false,
|
|
153
|
-
}
|
|
151
|
+
const recovery = managedRuntimeCapability()
|
|
152
|
+
const maintenance = maintenanceLifecycle.snapshot()
|
|
154
153
|
const features = {
|
|
155
154
|
claude: claudeAvailable,
|
|
156
155
|
codex: codexAvailable,
|
|
@@ -180,6 +179,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
180
179
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
181
180
|
res.json({
|
|
182
181
|
...checks,
|
|
182
|
+
server_version: managedServerVersion(),
|
|
183
|
+
server_instance_id: getServerInstanceId(),
|
|
184
|
+
boot_id: serverMetrics.bootId,
|
|
185
|
+
generation_id: getServerGenerationId(),
|
|
183
186
|
features,
|
|
184
187
|
voice,
|
|
185
188
|
whisper_health,
|
|
@@ -188,6 +191,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
188
191
|
capabilities: {
|
|
189
192
|
transcription,
|
|
190
193
|
recovery,
|
|
194
|
+
maintenance: {
|
|
195
|
+
state: maintenance.state,
|
|
196
|
+
admissionsOpen: maintenance.admissionsOpen,
|
|
197
|
+
carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
|
|
198
|
+
},
|
|
191
199
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
192
200
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
193
201
|
},
|
|
@@ -220,12 +228,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
220
228
|
},
|
|
221
229
|
transcription,
|
|
222
230
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
223
|
-
recovery:
|
|
224
|
-
status: false,
|
|
225
|
-
restartWhisper: false,
|
|
226
|
-
restartServer: false,
|
|
227
|
-
managed: false,
|
|
228
|
-
},
|
|
231
|
+
recovery: managedRuntimeCapability(),
|
|
229
232
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
230
233
|
},
|
|
231
234
|
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express'
|
|
2
|
+
import {
|
|
3
|
+
MaintenanceLifecycleError,
|
|
4
|
+
maintenanceErrorPayload,
|
|
5
|
+
maintenanceLifecycle,
|
|
6
|
+
type MaintenanceDrainRequest,
|
|
7
|
+
type MaintenanceOperationCredentials,
|
|
8
|
+
type MaintenanceOperationIdentity,
|
|
9
|
+
type MaintenanceOperationKind,
|
|
10
|
+
type MaintenanceOperationScope,
|
|
11
|
+
type MaintenancePostcondition,
|
|
12
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
13
|
+
import { managedRuntimeCapability, managedServerVersion, getServerGenerationId } from '../lib/managed-runtime.js'
|
|
14
|
+
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
15
|
+
import { serverMetrics } from '../lib/server-metrics.js'
|
|
16
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
17
|
+
import { getWhisperHealth } from '../lib/whisper-local.js'
|
|
18
|
+
import { getActiveTranscriptionSessionCount } from './transcribe-stream.js'
|
|
19
|
+
|
|
20
|
+
export const maintenanceRouter = Router()
|
|
21
|
+
|
|
22
|
+
function isLoopback(req: Request): boolean {
|
|
23
|
+
const address = req.socket.remoteAddress ?? ''
|
|
24
|
+
return address === '::1' || address === '127.0.0.1' || address.startsWith('127.')
|
|
25
|
+
|| address.startsWith('::ffff:127.')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bodyRecord(body: unknown): Record<string, unknown> {
|
|
29
|
+
return body && typeof body === 'object' ? body as Record<string, unknown> : {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function operationIdentity(body: unknown): MaintenanceOperationIdentity {
|
|
33
|
+
const value = bodyRecord(body)
|
|
34
|
+
return {
|
|
35
|
+
serverInstanceId: typeof value.serverInstanceId === 'string' ? value.serverInstanceId : '',
|
|
36
|
+
bootId: typeof value.bootId === 'string' ? value.bootId : '',
|
|
37
|
+
generationId: typeof value.generationId === 'string' ? value.generationId : '',
|
|
38
|
+
operationId: typeof value.operationId === 'string' ? value.operationId : '',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function drainRequest(body: unknown): MaintenanceDrainRequest {
|
|
43
|
+
const value = bodyRecord(body)
|
|
44
|
+
return {
|
|
45
|
+
...operationIdentity(value),
|
|
46
|
+
operationKind: value.operationKind as MaintenanceOperationKind,
|
|
47
|
+
scope: value.scope as MaintenanceOperationScope,
|
|
48
|
+
postcondition: value.postcondition as MaintenancePostcondition,
|
|
49
|
+
nonceSha256: typeof value.nonceSha256 === 'string' ? value.nonceSha256 : '',
|
|
50
|
+
authorizedSuccessorGenerations: Array.isArray(value.authorizedSuccessorGenerations)
|
|
51
|
+
? value.authorizedSuccessorGenerations.filter((item): item is string => typeof item === 'string')
|
|
52
|
+
: [],
|
|
53
|
+
...(typeof value.ttlMs === 'number' ? { ttlMs: value.ttlMs } : {}),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function operationCredentials(req: Request): MaintenanceOperationCredentials {
|
|
58
|
+
return {
|
|
59
|
+
leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
|
|
60
|
+
? req.headers['x-cos-maintenance-lease']
|
|
61
|
+
: undefined,
|
|
62
|
+
operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
|
|
63
|
+
? req.headers['x-cos-maintenance-operation']
|
|
64
|
+
: undefined,
|
|
65
|
+
nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
|
|
66
|
+
? req.headers['x-cos-maintenance-nonce']
|
|
67
|
+
: undefined,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
|
|
72
|
+
const jobs = getQueryJobRuntimeHealth()
|
|
73
|
+
const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
|
|
74
|
+
const managed = managedRuntimeCapability()
|
|
75
|
+
const tracked = maintenanceLifecycle.snapshot(credentials, {
|
|
76
|
+
recording_session: activeTranscriptionSessions,
|
|
77
|
+
})
|
|
78
|
+
const untrackedDurableRuns = Math.max(0, jobs.activeRuns - (tracked.activeByKind.durable_query ?? 0))
|
|
79
|
+
const lifecycle = untrackedDurableRuns > 0
|
|
80
|
+
? maintenanceLifecycle.snapshot(credentials, {
|
|
81
|
+
durable_query_runtime: untrackedDurableRuns,
|
|
82
|
+
recording_session: activeTranscriptionSessions,
|
|
83
|
+
})
|
|
84
|
+
: tracked
|
|
85
|
+
return {
|
|
86
|
+
contractVersion: managed.contractVersion,
|
|
87
|
+
managed: managed.managed,
|
|
88
|
+
serverVersion: managedServerVersion(),
|
|
89
|
+
generationId: getServerGenerationId(),
|
|
90
|
+
serverInstanceId: getServerInstanceId(),
|
|
91
|
+
bootId: serverMetrics.bootId,
|
|
92
|
+
activeJobs: jobs.activeRuns,
|
|
93
|
+
activeTranscriptionSessions,
|
|
94
|
+
shuttingDown: jobs.shuttingDown,
|
|
95
|
+
durableStoreState: jobs.store.state,
|
|
96
|
+
lifecycle,
|
|
97
|
+
safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown,
|
|
98
|
+
whisper: getWhisperHealth(),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sendLifecycleError(res: Response, error: unknown) {
|
|
103
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
104
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
105
|
+
res.status(error.status).json(maintenanceErrorPayload(error))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function requireLoopback(req: Request, res: Response): boolean {
|
|
112
|
+
if (isLoopback(req)) return true
|
|
113
|
+
res.status(403).json({ error: 'loopback_required', retryable: false })
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
maintenanceRouter.get('/maintenance/status', (req, res) => {
|
|
118
|
+
res.json(statusSnapshot(operationCredentials(req)))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
maintenanceRouter.post('/maintenance/drain', (req, res) => {
|
|
122
|
+
if (!requireLoopback(req, res)) return
|
|
123
|
+
try {
|
|
124
|
+
const leaseId = maintenanceLifecycle.beginDrain(drainRequest(req.body))
|
|
125
|
+
res.json({ leaseId, ...statusSnapshot() })
|
|
126
|
+
} catch (error) {
|
|
127
|
+
sendLifecycleError(res, error)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
maintenanceRouter.post('/maintenance/drain/adopt', (req, res) => {
|
|
132
|
+
if (!requireLoopback(req, res)) return
|
|
133
|
+
const credentials = operationCredentials(req)
|
|
134
|
+
try {
|
|
135
|
+
maintenanceLifecycle.adoptDrain(operationIdentity(req.body), credentials)
|
|
136
|
+
res.json({ adopted: true, ...statusSnapshot(credentials) })
|
|
137
|
+
} catch (error) {
|
|
138
|
+
sendLifecycleError(res, error)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
maintenanceRouter.post('/maintenance/drain/release', (req, res) => {
|
|
143
|
+
if (!requireLoopback(req, res)) return
|
|
144
|
+
try {
|
|
145
|
+
maintenanceLifecycle.releaseDrain(operationIdentity(req.body), operationCredentials(req))
|
|
146
|
+
res.json({ released: true, ...statusSnapshot() })
|
|
147
|
+
} catch (error) {
|
|
148
|
+
sendLifecycleError(res, error)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
maintenanceRouter.post('/maintenance/drain/cancel', (req, res) => {
|
|
153
|
+
if (!requireLoopback(req, res)) return
|
|
154
|
+
try {
|
|
155
|
+
maintenanceLifecycle.cancelDrain(operationIdentity(req.body), operationCredentials(req))
|
|
156
|
+
res.json({ canceled: true, ...statusSnapshot() })
|
|
157
|
+
} catch (error) {
|
|
158
|
+
sendLifecycleError(res, error)
|
|
159
|
+
}
|
|
160
|
+
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type TranscriptGapReport,
|
|
41
41
|
} from './transcribe-stream.js'
|
|
42
42
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
43
|
+
import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
|
|
43
44
|
|
|
44
45
|
interface MeetingSessionSource {
|
|
45
46
|
getTranscript(sessionId: string): string | null
|
|
@@ -177,6 +178,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
177
178
|
|
|
178
179
|
router.post('/meeting/save', async (req, res) => {
|
|
179
180
|
let lockedSessionId: string | null = null
|
|
181
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
180
182
|
try {
|
|
181
183
|
const body = req.body as Record<string, unknown> | undefined
|
|
182
184
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
@@ -219,6 +221,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
219
221
|
res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
|
|
220
222
|
return
|
|
221
223
|
}
|
|
224
|
+
// Saving/finalizing an already-recording session is a drain
|
|
225
|
+
// continuation. It must remain admitted so existing audio can reach a
|
|
226
|
+
// durable terminal while all genuinely new work is closed.
|
|
227
|
+
maintenanceLease = acquireMaintenanceWork('meeting_save', { allowDuringDrain: true })
|
|
222
228
|
savingSessions.add(sessionId)
|
|
223
229
|
lockedSessionId = sessionId
|
|
224
230
|
|
|
@@ -300,20 +306,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
300
306
|
res.json(publicSaveResponse(saved))
|
|
301
307
|
|
|
302
308
|
if (audioWritesReady && pendingAudioDir && chunkEntries.length > 0) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
// Acquire before the foreground save lease can leave scope: there is
|
|
310
|
+
// no zero-count proof gap between pending-audio handoff and the queued
|
|
311
|
+
// background finalizer.
|
|
312
|
+
const batchLease = acquireMaintenanceWork('meeting_batch_finalization', {
|
|
313
|
+
allowDuringDrain: true,
|
|
314
|
+
phase: 'queued',
|
|
315
|
+
})
|
|
316
|
+
const task = Promise.resolve().then(() => {
|
|
317
|
+
batchLease.setPhase('active')
|
|
318
|
+
return finalizeBatch({
|
|
319
|
+
audioDir: pendingAudioDir,
|
|
320
|
+
entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
|
|
321
|
+
streamingWordCount: countWords(transcript),
|
|
322
|
+
meetingPath: saved.filepath,
|
|
323
|
+
sidecarPath: saved.sidecarPath,
|
|
324
|
+
runBatch,
|
|
325
|
+
})
|
|
310
326
|
}).catch(error => {
|
|
311
327
|
// Raw audio deliberately remains for the existing two-hour cleanup.
|
|
312
328
|
console.error(
|
|
313
329
|
`[meeting/save] Batch finalization failed for ${sessionId}: `
|
|
314
330
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
315
331
|
)
|
|
316
|
-
})
|
|
332
|
+
}).finally(() => batchLease.release())
|
|
317
333
|
scheduleBackground(task)
|
|
318
334
|
}
|
|
319
335
|
} catch (error) {
|
|
@@ -324,6 +340,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
324
340
|
console.error('[meeting/save] Finalization failed:', error)
|
|
325
341
|
res.status(500).json({ error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
326
342
|
} finally {
|
|
343
|
+
maintenanceLease?.release()
|
|
327
344
|
if (lockedSessionId) savingSessions.delete(lockedSessionId)
|
|
328
345
|
}
|
|
329
346
|
})
|