@gotcos/glasses-server 6.12.7 → 6.13.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 +7 -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 +5 -2
- package/server/index.ts +92 -35
- 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/query-job-coordinator.ts +36 -4
- package/server/lib/query-job-runtime.ts +38 -26
- 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/query.ts +52 -24
- package/server/routes/transcribe-stream.ts +49 -3
- package/server/routes/transcribe.ts +14 -0
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
import { tryInstantResponse } from '../lib/response-cache.js'
|
|
15
15
|
import crypto from 'node:crypto'
|
|
16
16
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
17
|
+
import {
|
|
18
|
+
acquireMaintenanceWork,
|
|
19
|
+
MaintenanceLifecycleError,
|
|
20
|
+
type MaintenanceWorkLease,
|
|
21
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
17
22
|
|
|
18
23
|
export const openaiCompatRouter = Router()
|
|
19
24
|
|
|
@@ -131,6 +136,27 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
131
136
|
})
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
// Admission closes before cache lookup, dedup bookkeeping, latency logging,
|
|
140
|
+
// or provider work. Maintenance therefore has one linearization point for
|
|
141
|
+
// every OpenAI-compatible request, including instant and duplicate replies.
|
|
142
|
+
let maintenanceLease: MaintenanceWorkLease
|
|
143
|
+
try {
|
|
144
|
+
maintenanceLease = acquireMaintenanceWork('openai_query')
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
147
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
148
|
+
return res.status(error.status).json({
|
|
149
|
+
error: {
|
|
150
|
+
message: error.message,
|
|
151
|
+
type: 'server_error',
|
|
152
|
+
code: error.code,
|
|
153
|
+
retryable: error.retryable,
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
|
|
135
161
|
// Log request entry — tells us if Even sends stream: true or false
|
|
136
162
|
console.log(`[g2] Request: stream=${!!stream}, query="${query.slice(0, 50)}"`)
|
|
@@ -184,11 +210,12 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
184
210
|
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
|
185
211
|
res.write('data: [DONE]\n\n')
|
|
186
212
|
res.end()
|
|
213
|
+
maintenanceLease.release()
|
|
187
214
|
return
|
|
188
215
|
}
|
|
189
216
|
|
|
190
217
|
// Non-streaming cache response
|
|
191
|
-
|
|
218
|
+
const response = res.json({
|
|
192
219
|
id: completionId,
|
|
193
220
|
object: 'chat.completion',
|
|
194
221
|
created: timestamp,
|
|
@@ -200,6 +227,8 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
200
227
|
}],
|
|
201
228
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
202
229
|
})
|
|
230
|
+
maintenanceLease.release()
|
|
231
|
+
return response
|
|
203
232
|
}
|
|
204
233
|
|
|
205
234
|
// ── Dedup guard — if same query is already in-flight within 2s, reuse result ──
|
|
@@ -241,9 +270,10 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
241
270
|
res.write(`data: ${JSON.stringify({ id: completionId, object: 'chat.completion.chunk', created: timestamp, model: responseModel, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`)
|
|
242
271
|
res.write('data: [DONE]\n\n')
|
|
243
272
|
res.end()
|
|
273
|
+
maintenanceLease.release()
|
|
244
274
|
return
|
|
245
275
|
}
|
|
246
|
-
|
|
276
|
+
const response = res.json({
|
|
247
277
|
id: completionId,
|
|
248
278
|
object: 'chat.completion',
|
|
249
279
|
created: timestamp,
|
|
@@ -251,6 +281,8 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
251
281
|
choices: [{ index: 0, message: { role: 'assistant', content: dedupResult }, finish_reason: 'stop' }],
|
|
252
282
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
253
283
|
})
|
|
284
|
+
maintenanceLease.release()
|
|
285
|
+
return response
|
|
254
286
|
} catch {
|
|
255
287
|
// Original request failed — fall through to make a fresh request
|
|
256
288
|
}
|
|
@@ -311,11 +343,12 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
311
343
|
}
|
|
312
344
|
},
|
|
313
345
|
onDone: (fullText) => {
|
|
314
|
-
|
|
315
|
-
done
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
346
|
+
try {
|
|
347
|
+
if (!done) {
|
|
348
|
+
done = true
|
|
349
|
+
resolveInflight!(fullText || '')
|
|
350
|
+
inflightQueries.delete(dedupKey)
|
|
351
|
+
logLatency({
|
|
319
352
|
timestamp: new Date().toISOString(),
|
|
320
353
|
query: query.slice(0, 50),
|
|
321
354
|
ttfb_ms: actualTtfbMs,
|
|
@@ -325,35 +358,42 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
325
358
|
contextInjected: /\b(schedule|calendar|meeting|task|tasks|today|tomorrow|next meeting|who do i meet|what's next)\b/i.test(query),
|
|
326
359
|
cacheHit: false,
|
|
327
360
|
stream_requested: true,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
361
|
+
})
|
|
362
|
+
// Final chunk with finish_reason
|
|
363
|
+
const finalChunk = {
|
|
331
364
|
id: completionId,
|
|
332
365
|
object: 'chat.completion.chunk',
|
|
333
366
|
created: timestamp,
|
|
334
367
|
model: responseModel,
|
|
335
368
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
369
|
+
}
|
|
370
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
|
371
|
+
res.write('data: [DONE]\n\n')
|
|
372
|
+
res.end()
|
|
336
373
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
res.end()
|
|
374
|
+
} finally {
|
|
375
|
+
maintenanceLease.release()
|
|
340
376
|
}
|
|
341
377
|
},
|
|
342
378
|
onError: (error) => {
|
|
343
|
-
|
|
344
|
-
done
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
379
|
+
try {
|
|
380
|
+
if (!done) {
|
|
381
|
+
done = true
|
|
382
|
+
rejectInflight!(new Error(error))
|
|
383
|
+
inflightQueries.delete(dedupKey)
|
|
384
|
+
const errChunk = {
|
|
348
385
|
id: completionId,
|
|
349
386
|
object: 'chat.completion.chunk',
|
|
350
387
|
created: timestamp,
|
|
351
388
|
model: responseModel,
|
|
352
389
|
choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
|
|
390
|
+
}
|
|
391
|
+
res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
|
|
392
|
+
res.write('data: [DONE]\n\n')
|
|
393
|
+
res.end()
|
|
353
394
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
res.end()
|
|
395
|
+
} finally {
|
|
396
|
+
maintenanceLease.release()
|
|
357
397
|
}
|
|
358
398
|
},
|
|
359
399
|
onToolStatus: (status) => {
|
|
@@ -367,6 +407,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
367
407
|
// Persist session ID for multi-turn context on subsequent G2 queries
|
|
368
408
|
g2SessionId = returnedSid
|
|
369
409
|
} catch (err: any) {
|
|
410
|
+
maintenanceLease.release()
|
|
370
411
|
if (!done) {
|
|
371
412
|
done = true
|
|
372
413
|
rejectInflight!(err)
|
|
@@ -399,6 +440,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
399
440
|
const fail = (error: unknown) => {
|
|
400
441
|
if (settled) return
|
|
401
442
|
settled = true
|
|
443
|
+
maintenanceLease.release()
|
|
402
444
|
const err = error instanceof Error ? error : new Error(String(error))
|
|
403
445
|
rejectInflight!(err)
|
|
404
446
|
inflightQueries.delete(dedupKey)
|
|
@@ -412,21 +454,25 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
412
454
|
onDone: (fullText) => {
|
|
413
455
|
if (settled) return
|
|
414
456
|
settled = true
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
457
|
+
try {
|
|
458
|
+
const text = fullText || result
|
|
459
|
+
resolveInflight!(text)
|
|
460
|
+
inflightQueries.delete(dedupKey)
|
|
461
|
+
logLatency({
|
|
462
|
+
timestamp: new Date().toISOString(),
|
|
463
|
+
query: query.slice(0, 50),
|
|
464
|
+
ttfb_ms: nsFirstChunkMs,
|
|
465
|
+
total_ms: Date.now() - requestReceivedAt,
|
|
466
|
+
model: resolvedModel,
|
|
467
|
+
resumed: !!currentSessionIdNS,
|
|
468
|
+
contextInjected: /\b(schedule|calendar|meeting|task|tasks|today|tomorrow)\b/i.test(query),
|
|
469
|
+
cacheHit: false,
|
|
470
|
+
stream_requested: false,
|
|
471
|
+
})
|
|
472
|
+
resolve(text)
|
|
473
|
+
} finally {
|
|
474
|
+
maintenanceLease.release()
|
|
475
|
+
}
|
|
430
476
|
},
|
|
431
477
|
onError: (error) => {
|
|
432
478
|
fail(new Error(error))
|
|
@@ -39,6 +39,13 @@ import { logTokenAudit } from '../lib/token-audit.js'
|
|
|
39
39
|
import { atomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
40
40
|
import { dataPath } from '../lib/data-dir.js'
|
|
41
41
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
42
|
+
import {
|
|
43
|
+
acquireMaintenanceWork,
|
|
44
|
+
maintenanceAdmissionsOpen,
|
|
45
|
+
MaintenanceLifecycleError,
|
|
46
|
+
maintenanceErrorPayload,
|
|
47
|
+
type MaintenanceWorkLease,
|
|
48
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
42
49
|
|
|
43
50
|
export const promptDraftsRouter = Router()
|
|
44
51
|
|
|
@@ -138,6 +145,12 @@ function sanitizeTranscript(draftId: string, text: string, learnInline = true):
|
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
async function sendDraftError(res: Response, draftId: string, err: any): Promise<void> {
|
|
148
|
+
// Maintenance rejection is an admission result, not a draft failure. Keep
|
|
149
|
+
// the recoverable draft untouched so the phone can retry after maintenance.
|
|
150
|
+
if (err instanceof MaintenanceLifecycleError) {
|
|
151
|
+
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|
|
152
|
+
return void res.status(err.status).json({ ...maintenanceErrorPayload(err), draftPreserved: true })
|
|
153
|
+
}
|
|
141
154
|
if (err instanceof NoSpeechDetectedError) return void res.status(204).send()
|
|
142
155
|
if (err instanceof OpenAIWhisperBudgetExhaustedError) {
|
|
143
156
|
await markPromptDraftError(draftId, err.message)
|
|
@@ -211,31 +224,53 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
211
224
|
return { draftId, text: finalText, recovered: true, chunkCount: finalized.receivedChunkIndexes.length, missingChunks: getMissingChunkIndexes(finalized), expiresAt: finalized.expiresAt }
|
|
212
225
|
}
|
|
213
226
|
|
|
214
|
-
const prunedAtBoot = prunePromptDrafts()
|
|
227
|
+
const prunedAtBoot = maintenanceAdmissionsOpen() ? prunePromptDrafts() : 0
|
|
215
228
|
if (prunedAtBoot) console.log(`[prompt-draft] pruned ${prunedAtBoot} expired draft(s)`)
|
|
216
|
-
const pruneTimer = setInterval(() =>
|
|
229
|
+
const pruneTimer = setInterval(() => {
|
|
230
|
+
if (maintenanceAdmissionsOpen()) prunePromptDrafts()
|
|
231
|
+
}, 60 * 60 * 1000)
|
|
217
232
|
pruneTimer.unref?.()
|
|
218
233
|
|
|
219
234
|
promptDraftsRouter.post('/prompt-drafts/start', (req, res) => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
235
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
236
|
+
try {
|
|
237
|
+
maintenanceLease = acquireMaintenanceWork('prompt_draft_write')
|
|
238
|
+
const requestedId = typeof req.body?.recoveryId === 'string' ? req.body.recoveryId : undefined
|
|
239
|
+
const meta = createPromptDraft(requestedId)
|
|
240
|
+
res.json({ draftId: meta.draftId, recoveryId: requestedId ?? meta.draftId, remapped: Boolean(requestedId && requestedId !== meta.draftId), expiresAt: meta.expiresAt, status: meta.status })
|
|
241
|
+
} catch (err: any) {
|
|
242
|
+
if (err instanceof MaintenanceLifecycleError) {
|
|
243
|
+
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|
|
244
|
+
res.status(err.status).json(maintenanceErrorPayload(err))
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
res.status(err.status ?? 500).json({ error: err.message })
|
|
248
|
+
} finally {
|
|
249
|
+
maintenanceLease?.release()
|
|
250
|
+
}
|
|
223
251
|
})
|
|
224
252
|
|
|
225
253
|
promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
254
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
226
255
|
try {
|
|
227
256
|
const raw = Array.isArray(req.query.chunkIndex) ? req.query.chunkIndex[0] : req.query.chunkIndex
|
|
228
257
|
const chunkIndex = Number(raw)
|
|
229
258
|
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= MAX_CHUNKS) return res.status(400).json({ error: 'invalid chunkIndex' })
|
|
230
|
-
const audio = await readRawBody(req)
|
|
231
|
-
if (audio.length < 44) return res.status(400).json({ error: 'audio too short' })
|
|
232
259
|
const before = loadPromptDraftMeta(req.params.draftId)
|
|
233
260
|
if (!before) return res.status(404).json({ error: 'draft not found' })
|
|
261
|
+
maintenanceLease = acquireMaintenanceWork('prompt_draft_write')
|
|
262
|
+
const audio = await readRawBody(req)
|
|
263
|
+
if (audio.length < 44) return res.status(400).json({ error: 'audio too short' })
|
|
234
264
|
const existingBytes = before.chunkBytes[String(chunkIndex)] ?? 0
|
|
235
265
|
const nextTotal = Object.values(before.chunkBytes).reduce((sum, bytes) => sum + bytes, 0) - existingBytes + audio.length
|
|
236
266
|
if (nextTotal > MAX_DRAFT_BYTES) return res.status(413).json({ error: 'prompt draft too large' })
|
|
237
267
|
const meta = await savePromptDraftChunk(req.params.draftId, chunkIndex, audio)
|
|
268
|
+
const warmLease = acquireMaintenanceWork('prompt_draft_warm', {
|
|
269
|
+
allowDuringDrain: true,
|
|
270
|
+
phase: 'queued',
|
|
271
|
+
})
|
|
238
272
|
warmTail = warmTail.then(async () => {
|
|
273
|
+
warmLease.setPhase('active')
|
|
239
274
|
const text = await transcribeChunk(req.params.draftId, chunkIndex, audio, 'fast', 'warm')
|
|
240
275
|
// The durability ACK above remains immediate. Publish the optional warm
|
|
241
276
|
// transcript only after rechecking that this exact audio still owns the
|
|
@@ -247,10 +282,16 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
|
247
282
|
})
|
|
248
283
|
}).catch(err => {
|
|
249
284
|
console.warn(`[prompt-draft] warm transcription failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
|
|
250
|
-
})
|
|
285
|
+
}).finally(() => warmLease.release())
|
|
251
286
|
res.json({ draftId: meta.draftId, chunkIndex, acked: true, receivedChunkIndexes: meta.receivedChunkIndexes, chunkBytes: meta.chunkBytes[String(chunkIndex)] ?? audio.length, transcriptPending: true, expiresAt: meta.expiresAt })
|
|
252
287
|
} catch (err: any) {
|
|
288
|
+
if (err instanceof MaintenanceLifecycleError) {
|
|
289
|
+
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|
|
290
|
+
return void res.status(err.status).json(maintenanceErrorPayload(err))
|
|
291
|
+
}
|
|
253
292
|
res.status(err.status ?? (err.message === 'draft not found' ? 404 : 500)).json({ error: err.message })
|
|
293
|
+
} finally {
|
|
294
|
+
maintenanceLease?.release()
|
|
254
295
|
}
|
|
255
296
|
})
|
|
256
297
|
|
|
@@ -262,7 +303,9 @@ async function finalizeRequest(req: any, res: Response): Promise<void> {
|
|
|
262
303
|
const key = `${req.params.draftId}:${mode}`
|
|
263
304
|
let job = finalizeJobs.get(key)
|
|
264
305
|
if (!job) {
|
|
306
|
+
const finalizeLease = acquireMaintenanceWork('prompt_draft_finalize')
|
|
265
307
|
job = finalizeDraft(req.params.draftId, mode, routeAutoClean(req), abort.signal)
|
|
308
|
+
.finally(() => finalizeLease.release())
|
|
266
309
|
finalizeJobs.set(key, job)
|
|
267
310
|
job.finally(() => finalizeJobs.delete(key)).catch(() => {})
|
|
268
311
|
}
|
package/server/routes/query.ts
CHANGED
|
@@ -9,6 +9,11 @@ import { normalizeEffortPreference, normalizeModelPreference } from '../../share
|
|
|
9
9
|
import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
|
|
10
10
|
import { getMediaStore } from '../lib/media-store.js'
|
|
11
11
|
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
12
|
+
import {
|
|
13
|
+
acquireMaintenanceWork,
|
|
14
|
+
MaintenanceLifecycleError,
|
|
15
|
+
maintenanceErrorPayload,
|
|
16
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
12
17
|
|
|
13
18
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
14
19
|
WebSearch: 'Searching web...',
|
|
@@ -19,6 +24,16 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
|
19
24
|
export const queryRouter = Router()
|
|
20
25
|
|
|
21
26
|
queryRouter.post('/query', async (req, res) => {
|
|
27
|
+
let maintenanceLease
|
|
28
|
+
try {
|
|
29
|
+
maintenanceLease = acquireMaintenanceWork('legacy_query')
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
32
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
33
|
+
return res.status(error.status).json(maintenanceErrorPayload(error))
|
|
34
|
+
}
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
22
37
|
const { query, sessionId, model, effort, reference, globalMsgNum } = req.body
|
|
23
38
|
const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
|
|
24
39
|
? req.body.activityToolMode
|
|
@@ -31,8 +46,10 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
31
46
|
resolvedAttachments = await resolveQueryAttachments(req.body)
|
|
32
47
|
} catch (err) {
|
|
33
48
|
if (err instanceof QueryAttachmentError) {
|
|
49
|
+
maintenanceLease.release()
|
|
34
50
|
return res.status(err.status).json({ error: err.code, detail: err.message })
|
|
35
51
|
}
|
|
52
|
+
maintenanceLease.release()
|
|
36
53
|
return res.status(500).json({ error: errMsg(err) })
|
|
37
54
|
}
|
|
38
55
|
const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
|
|
@@ -42,6 +59,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
42
59
|
|
|
43
60
|
// Vision queries can have an empty query (default to "describe what you see")
|
|
44
61
|
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !imageInputs) {
|
|
62
|
+
maintenanceLease.release()
|
|
45
63
|
return res.status(400).json({ error: 'query string or image required' })
|
|
46
64
|
}
|
|
47
65
|
|
|
@@ -107,34 +125,43 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
107
125
|
}
|
|
108
126
|
},
|
|
109
127
|
} : {}),
|
|
110
|
-
onDone: (fullText, model, cliSessionId, metadata) => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
done = true
|
|
121
|
-
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
122
|
-
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
123
|
-
const payload = {
|
|
124
|
-
text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
|
|
125
|
-
...(attachments.length > 0 ? { attachments } : {}),
|
|
128
|
+
onDone: async (fullText, model, cliSessionId, metadata) => {
|
|
129
|
+
try {
|
|
130
|
+
// Durable association is independent of the SSE socket.
|
|
131
|
+
// Backgrounding the phone cannot leave request media reserved until
|
|
132
|
+
// expiry or make maintenance proof outrun the pending write.
|
|
133
|
+
if (resolvedAttachments.ids.length > 0) {
|
|
134
|
+
await getMediaStore().associate(resolvedAttachments.ids, {
|
|
135
|
+
sessionId: sid,
|
|
136
|
+
...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
|
|
137
|
+
}).catch((err) => console.error('[query] attachment association failed:', err))
|
|
126
138
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
if (!done) {
|
|
140
|
+
done = true
|
|
141
|
+
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
142
|
+
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
143
|
+
const payload = {
|
|
144
|
+
text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
|
|
145
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
146
|
+
}
|
|
147
|
+
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
148
|
+
emitDisplay({ type: 'done', data: payload })
|
|
149
|
+
res.end()
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
maintenanceLease.release()
|
|
130
153
|
}
|
|
131
154
|
},
|
|
132
155
|
onError: (error) => {
|
|
133
|
-
|
|
134
|
-
done
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
156
|
+
try {
|
|
157
|
+
if (!done) {
|
|
158
|
+
done = true
|
|
159
|
+
res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`)
|
|
160
|
+
emitDisplay({ type: 'error', data: { error } })
|
|
161
|
+
res.end()
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
maintenanceLease.release()
|
|
138
165
|
}
|
|
139
166
|
},
|
|
140
167
|
}, validModel, imageInputs,
|
|
@@ -146,6 +173,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
146
173
|
{ abortSignal: abortController.signal, effort: validEffort },
|
|
147
174
|
)
|
|
148
175
|
} catch (err: unknown) {
|
|
176
|
+
maintenanceLease.release()
|
|
149
177
|
if (!done) {
|
|
150
178
|
done = true
|
|
151
179
|
res.write(`event: error\ndata: ${JSON.stringify({ error: errMsg(err) })}\n\n`)
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
type IndexRange,
|
|
44
44
|
} from '../lib/local-first-meetings-contract.js'
|
|
45
45
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
46
|
+
import {
|
|
47
|
+
acquireMaintenanceWork,
|
|
48
|
+
maintenanceAdmissionsOpen,
|
|
49
|
+
type MaintenanceWorkLease,
|
|
50
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
46
51
|
|
|
47
52
|
function ensurePrivateDirectory(path: string): void {
|
|
48
53
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -264,6 +269,12 @@ interface StreamChunkCompletionResponse {
|
|
|
264
269
|
|
|
265
270
|
const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
|
|
266
271
|
|
|
272
|
+
/** Conservative lifecycle count used by the local service manager. A restart
|
|
273
|
+
* is unsafe while any live transcription session owns audio state. */
|
|
274
|
+
export function getActiveTranscriptionSessionCount(): number {
|
|
275
|
+
return sessions.size
|
|
276
|
+
}
|
|
277
|
+
|
|
267
278
|
// Incremental chunk persistence — survive server restarts
|
|
268
279
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
269
280
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
@@ -600,12 +611,17 @@ export function isSessionDeleted(sessionId: string): boolean {
|
|
|
600
611
|
return deletedSessions.has(sessionId)
|
|
601
612
|
}
|
|
602
613
|
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
614
|
+
// A committed cross-boot maintenance operation owns durable state until the
|
|
615
|
+
// trusted controller adopts the candidate. Boot recovery must not mutate or
|
|
616
|
+
// promote sessions while that gate is closed.
|
|
617
|
+
if (maintenanceAdmissionsOpen()) {
|
|
618
|
+
recoverClosedSessions()
|
|
619
|
+
recoverSessions()
|
|
620
|
+
}
|
|
606
621
|
|
|
607
622
|
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
608
623
|
setInterval(() => {
|
|
624
|
+
if (!maintenanceAdmissionsOpen()) return
|
|
609
625
|
const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
610
626
|
for (const [id, session] of sessions) {
|
|
611
627
|
if (session.lastActivityAt < cutoff) {
|
|
@@ -1550,10 +1566,14 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
1550
1566
|
}
|
|
1551
1567
|
|
|
1552
1568
|
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
1569
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1553
1570
|
try {
|
|
1554
1571
|
// Reject a wrong Mac before consuming or persisting any upload bytes.
|
|
1555
1572
|
assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
|
|
1556
1573
|
const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
|
|
1574
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1575
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1576
|
+
})
|
|
1557
1577
|
const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
1558
1578
|
const clientSpeaker = (req.query.speaker as string) || 'Unknown'
|
|
1559
1579
|
const clientElapsedRaw = Number(req.query.elapsed)
|
|
@@ -1575,15 +1595,21 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
|
1575
1595
|
}))
|
|
1576
1596
|
} catch (err: unknown) {
|
|
1577
1597
|
sendStreamError(res, err)
|
|
1598
|
+
} finally {
|
|
1599
|
+
maintenanceLease?.release()
|
|
1578
1600
|
}
|
|
1579
1601
|
})
|
|
1580
1602
|
|
|
1581
1603
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (req, res) => {
|
|
1604
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1582
1605
|
try {
|
|
1583
1606
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1584
1607
|
const body = req.body ?? {}
|
|
1585
1608
|
const sessionId = String(body.sessionId ?? '')
|
|
1586
1609
|
validateSessionId(sessionId)
|
|
1610
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1611
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1612
|
+
})
|
|
1587
1613
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1588
1614
|
const session = getSession(sessionId)
|
|
1589
1615
|
const startTime = typeof body.startTime === 'number' && Number.isFinite(body.startTime)
|
|
@@ -1596,14 +1622,20 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
|
|
|
1596
1622
|
res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
|
|
1597
1623
|
} catch (err: unknown) {
|
|
1598
1624
|
sendStreamError(res, err)
|
|
1625
|
+
} finally {
|
|
1626
|
+
maintenanceLease?.release()
|
|
1599
1627
|
}
|
|
1600
1628
|
})
|
|
1601
1629
|
|
|
1602
1630
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chunks', async (req, res) => {
|
|
1631
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1603
1632
|
try {
|
|
1604
1633
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1605
1634
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1606
1635
|
validateSessionId(sessionId)
|
|
1636
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1637
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1638
|
+
})
|
|
1607
1639
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1608
1640
|
const body = req.body ?? {}
|
|
1609
1641
|
const chunkIndex = Number(body.chunkIndex)
|
|
@@ -1636,14 +1668,20 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
|
|
|
1636
1668
|
res.json({ ...result, offlineReplay: true })
|
|
1637
1669
|
} catch (err: unknown) {
|
|
1638
1670
|
sendStreamError(res, err)
|
|
1671
|
+
} finally {
|
|
1672
|
+
maintenanceLease?.release()
|
|
1639
1673
|
}
|
|
1640
1674
|
})
|
|
1641
1675
|
|
|
1642
1676
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/finalize', async (req, res) => {
|
|
1677
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1643
1678
|
try {
|
|
1644
1679
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1645
1680
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1646
1681
|
validateSessionId(sessionId)
|
|
1682
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1683
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1684
|
+
})
|
|
1647
1685
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1648
1686
|
const chunks = getSessionChunks(sessionId)
|
|
1649
1687
|
if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
|
|
@@ -1662,16 +1700,22 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
|
|
|
1662
1700
|
})
|
|
1663
1701
|
} catch (err: unknown) {
|
|
1664
1702
|
sendStreamError(res, err)
|
|
1703
|
+
} finally {
|
|
1704
|
+
maintenanceLease?.release()
|
|
1665
1705
|
}
|
|
1666
1706
|
})
|
|
1667
1707
|
|
|
1668
1708
|
transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) => {
|
|
1709
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1669
1710
|
try {
|
|
1670
1711
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1671
1712
|
const body = req.body ?? {}
|
|
1672
1713
|
const sessionId = String(body.sessionId ?? '')
|
|
1673
1714
|
const chunkIndex = Number(body.chunkIndex)
|
|
1674
1715
|
validateSessionId(sessionId)
|
|
1716
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1717
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1718
|
+
})
|
|
1675
1719
|
validateChunkIndex(chunkIndex)
|
|
1676
1720
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1677
1721
|
if (chunkIndex > 0 && !sessions.has(sessionId)) {
|
|
@@ -1702,6 +1746,8 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
|
|
|
1702
1746
|
res.json(result)
|
|
1703
1747
|
} catch (err: unknown) {
|
|
1704
1748
|
sendStreamError(res, err)
|
|
1749
|
+
} finally {
|
|
1750
|
+
maintenanceLease?.release()
|
|
1705
1751
|
}
|
|
1706
1752
|
})
|
|
1707
1753
|
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
OpenAIWhisperBudgetExhaustedError,
|
|
12
12
|
TranscriptionUnavailableError,
|
|
13
13
|
} from '../lib/transcribe-audio.js'
|
|
14
|
+
import {
|
|
15
|
+
acquireMaintenanceWork,
|
|
16
|
+
MaintenanceLifecycleError,
|
|
17
|
+
maintenanceErrorPayload,
|
|
18
|
+
type MaintenanceWorkLease,
|
|
19
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
14
20
|
|
|
15
21
|
export const transcribeRouter = Router()
|
|
16
22
|
|
|
@@ -23,7 +29,9 @@ function resolveMode(req: { body?: { mode?: string }; query?: { mode?: string |
|
|
|
23
29
|
|
|
24
30
|
// Accept raw binary body up to 25MB (Whisper limit).
|
|
25
31
|
transcribeRouter.post('/transcribe', async (req, res) => {
|
|
32
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
26
33
|
try {
|
|
34
|
+
maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
|
|
27
35
|
const chunks: Buffer[] = []
|
|
28
36
|
for await (const chunk of req) {
|
|
29
37
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
@@ -38,6 +46,10 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
38
46
|
console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
|
|
39
47
|
res.json({ text: result.text, backend: result.backend, mode: result.mode })
|
|
40
48
|
} catch (err: any) {
|
|
49
|
+
if (err instanceof MaintenanceLifecycleError) {
|
|
50
|
+
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|
|
51
|
+
return res.status(err.status).json(maintenanceErrorPayload(err))
|
|
52
|
+
}
|
|
41
53
|
if (err instanceof NoSpeechDetectedError) {
|
|
42
54
|
console.log(`[perf] /transcribe: DROPPED (hallucination or empty): ${err.rawText.length} chars`)
|
|
43
55
|
return res.status(204).send()
|
|
@@ -60,5 +72,7 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
60
72
|
})
|
|
61
73
|
}
|
|
62
74
|
res.status(500).json({ error: err.message })
|
|
75
|
+
} finally {
|
|
76
|
+
maintenanceLease?.release()
|
|
63
77
|
}
|
|
64
78
|
})
|