@gotcos/glasses-server 6.9.0 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/CHANGELOG.md +50 -0
- package/README.md +19 -1
- package/package.json +1 -1
- package/server/index.ts +40 -11
- package/server/lib/claude-bridge.ts +115 -12
- package/server/lib/codex-bridge.ts +151 -28
- package/server/lib/conversation.ts +194 -1
- package/server/lib/local-first-meetings-contract.ts +53 -0
- package/server/lib/model-router.ts +6 -6
- package/server/lib/query-job-coordinator.ts +580 -0
- package/server/lib/query-job-feature.ts +20 -0
- package/server/lib/query-job-runtime.ts +255 -0
- package/server/lib/query-job-store.ts +1102 -0
- package/server/lib/query-job-types.ts +358 -0
- package/server/lib/run-output-images.ts +44 -0
- package/server/routes/health.ts +68 -2
- package/server/routes/meeting.ts +31 -0
- package/server/routes/query-jobs.ts +294 -0
- package/server/routes/transcribe-stream.ts +234 -72
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
parseMediaAttachmentRefs,
|
|
4
|
+
parseMediaIdList,
|
|
5
|
+
type MediaAttachmentRef,
|
|
6
|
+
} from '../../shared/media-attachment.js'
|
|
7
|
+
|
|
8
|
+
export const QUERY_JOB_SCHEMA_VERSION = 1 as const
|
|
9
|
+
export const QUERY_JOB_PROTOCOL_VERSION = 1 as const
|
|
10
|
+
|
|
11
|
+
export const QUERY_JOB_LIMITS = Object.freeze({
|
|
12
|
+
promptChars: 48_000,
|
|
13
|
+
referenceQueryChars: 48_000,
|
|
14
|
+
referenceResponseChars: 128_000,
|
|
15
|
+
partialChars: 128_000,
|
|
16
|
+
terminalResponseChars: 128_000,
|
|
17
|
+
errorChars: 2_000,
|
|
18
|
+
activityChars: 2_000,
|
|
19
|
+
activityEntries: 64,
|
|
20
|
+
replayEvents: 256,
|
|
21
|
+
retainedDays: 7,
|
|
22
|
+
hydratedJobs: 500,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type QueryJobStatus =
|
|
26
|
+
| 'accepted'
|
|
27
|
+
| 'starting'
|
|
28
|
+
| 'running'
|
|
29
|
+
| 'answer_ready'
|
|
30
|
+
| 'completed'
|
|
31
|
+
| 'failed'
|
|
32
|
+
| 'canceled'
|
|
33
|
+
| 'interrupted'
|
|
34
|
+
|
|
35
|
+
export type QueryJobTerminalStatus = Extract<
|
|
36
|
+
QueryJobStatus,
|
|
37
|
+
'completed' | 'failed' | 'canceled' | 'interrupted'
|
|
38
|
+
>
|
|
39
|
+
|
|
40
|
+
export type QueryJobEventType =
|
|
41
|
+
| QueryJobStatus
|
|
42
|
+
| 'chunk'
|
|
43
|
+
| 'tool_status'
|
|
44
|
+
| 'activity_line'
|
|
45
|
+
| 'acknowledged'
|
|
46
|
+
|
|
47
|
+
export type QueryJobActivityMode = 'off' | 'status' | 'preview'
|
|
48
|
+
export type QueryJobActivityKind = 'status' | 'input' | 'output' | 'gap'
|
|
49
|
+
|
|
50
|
+
export interface QueryJobPromptReference {
|
|
51
|
+
query: string
|
|
52
|
+
response: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Immutable, persistence-safe request. Provider-only objects (paths, image
|
|
56
|
+
* bytes, AbortControllers, handoff runtime state) deliberately do not fit. */
|
|
57
|
+
export interface QueryJobRequest {
|
|
58
|
+
clientJobId: string
|
|
59
|
+
generation: number
|
|
60
|
+
query: string
|
|
61
|
+
sessionId: string
|
|
62
|
+
model?: string
|
|
63
|
+
effort?: string
|
|
64
|
+
messageEra?: string
|
|
65
|
+
globalMsgNum?: number
|
|
66
|
+
reference?: QueryJobPromptReference
|
|
67
|
+
handoffCode?: string
|
|
68
|
+
handoffLatest?: boolean
|
|
69
|
+
clientQueueItemId?: string
|
|
70
|
+
attachmentIds: string[]
|
|
71
|
+
attachmentRefs: MediaAttachmentRef[]
|
|
72
|
+
activityToolMode: QueryJobActivityMode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface QueryJobProviderLinkage {
|
|
76
|
+
provider?: 'claude' | 'codex'
|
|
77
|
+
resolvedModel?: string
|
|
78
|
+
cliSessionId?: string
|
|
79
|
+
claudeRunId?: string
|
|
80
|
+
codexRunId?: string
|
|
81
|
+
codexThreadId?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Path/id-free aggregate from output-image finalization. Values are bounded
|
|
85
|
+
* before journal persistence so terminal replay cannot smuggle arbitrary
|
|
86
|
+
* provider metadata. */
|
|
87
|
+
export interface QueryJobOutputImageStats {
|
|
88
|
+
published: number
|
|
89
|
+
attached: number
|
|
90
|
+
rejected: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface QueryJobError {
|
|
94
|
+
code: string
|
|
95
|
+
message: string
|
|
96
|
+
retryable?: boolean
|
|
97
|
+
retryAfterMs?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface QueryJobActivity {
|
|
101
|
+
eventSeq: number
|
|
102
|
+
at: string
|
|
103
|
+
kind: QueryJobActivityKind
|
|
104
|
+
text: string
|
|
105
|
+
repeatCount?: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface QueryJobSnapshot extends QueryJobProviderLinkage {
|
|
109
|
+
schemaVersion: typeof QUERY_JOB_SCHEMA_VERSION
|
|
110
|
+
jobId: string
|
|
111
|
+
clientJobId: string
|
|
112
|
+
generation: number
|
|
113
|
+
turnId: string
|
|
114
|
+
requestFingerprint: string
|
|
115
|
+
status: QueryJobStatus
|
|
116
|
+
eventSeq: number
|
|
117
|
+
oldestEventSeq: number
|
|
118
|
+
sessionId: string
|
|
119
|
+
requestedModel?: string
|
|
120
|
+
effort?: string
|
|
121
|
+
messageEra?: string
|
|
122
|
+
globalMsgNum?: number
|
|
123
|
+
handoffCode?: string
|
|
124
|
+
attachments: MediaAttachmentRef[]
|
|
125
|
+
partialText: string
|
|
126
|
+
partialTruncated: boolean
|
|
127
|
+
response?: string
|
|
128
|
+
responseTruncated?: boolean
|
|
129
|
+
outputImageStats?: QueryJobOutputImageStats
|
|
130
|
+
error?: QueryJobError
|
|
131
|
+
activity: QueryJobActivity[]
|
|
132
|
+
acceptedAt: string
|
|
133
|
+
startedAt?: string
|
|
134
|
+
answerReadyAt?: string
|
|
135
|
+
/** Fsynced only after the provider child exists and before prompt bytes are
|
|
136
|
+
* written. Run ids alone can be allocated before a child owns the session. */
|
|
137
|
+
providerOwnershipConfirmedAt?: string
|
|
138
|
+
updatedAt: string
|
|
139
|
+
completedAt?: string
|
|
140
|
+
acknowledgedAt?: string
|
|
141
|
+
orphanFenceUntil?: string
|
|
142
|
+
retentionUntil: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface QueryJobEvent {
|
|
146
|
+
type: QueryJobEventType
|
|
147
|
+
eventSeq: number
|
|
148
|
+
jobId: string
|
|
149
|
+
clientJobId: string
|
|
150
|
+
generation: number
|
|
151
|
+
status: QueryJobStatus
|
|
152
|
+
at: string
|
|
153
|
+
data: Record<string, unknown>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface QueryJobReplay {
|
|
157
|
+
events: QueryJobEvent[]
|
|
158
|
+
gap: boolean
|
|
159
|
+
reason?: 'cursor_ahead' | 'buffer_overflow'
|
|
160
|
+
oldestEventSeq: number
|
|
161
|
+
latestEventSeq: number
|
|
162
|
+
snapshot: QueryJobSnapshot
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface QueryJobStoreHealth {
|
|
166
|
+
state: 'new' | 'ready' | 'degraded'
|
|
167
|
+
bootId: string
|
|
168
|
+
hydratedJobs: number
|
|
169
|
+
retainedIdentities: number
|
|
170
|
+
subscribers: number
|
|
171
|
+
malformedRows: number
|
|
172
|
+
journalFailures: number
|
|
173
|
+
interruptedOnBoot: number
|
|
174
|
+
evictedHydratedJobs: number
|
|
175
|
+
lastErrorCode: string | null
|
|
176
|
+
lastSuccessfulWriteAt: string | null
|
|
177
|
+
rootFingerprint: string
|
|
178
|
+
counts: Record<QueryJobStatus, number>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
182
|
+
const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/
|
|
183
|
+
const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
|
|
184
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
185
|
+
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}\b/gi,
|
|
186
|
+
/\b(?:sk|xox[baprs]|gh[pousr])[-_][A-Za-z0-9_-]{12,}\b/gi,
|
|
187
|
+
/\b(?:api[_ -]?key|access[_ -]?token|authorization)\s*[:=]\s*[^\s,;]+/gi,
|
|
188
|
+
]
|
|
189
|
+
const PATH_PATTERNS: RegExp[] = [
|
|
190
|
+
/\/(?:Users|home|private|var|tmp|Volumes)\/[A-Za-z0-9_.@%+~/-]+/g,
|
|
191
|
+
/(?:[A-Za-z]:\\|\\\\)[^\s"']+/g,
|
|
192
|
+
/~\/[A-Za-z0-9_.@%+~/-]+/g,
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
export class QueryJobValidationError extends Error {
|
|
196
|
+
constructor(readonly code: string, message = code) {
|
|
197
|
+
super(message)
|
|
198
|
+
this.name = 'QueryJobValidationError'
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function requiredString(value: unknown, field: string, max: number): string {
|
|
203
|
+
if (typeof value !== 'string') throw new QueryJobValidationError(`invalid_${field}`)
|
|
204
|
+
const cleaned = value.replace(CONTROL_RE, '').trim()
|
|
205
|
+
if (!cleaned || cleaned.length > max) throw new QueryJobValidationError(`invalid_${field}`)
|
|
206
|
+
return cleaned
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function optionalString(value: unknown, field: string, max: number): string | undefined {
|
|
210
|
+
if (value == null || value === '') return undefined
|
|
211
|
+
return requiredString(value, field, max)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function boundedContent(value: unknown, field: string, max: number, allowEmpty = false): string {
|
|
215
|
+
if (typeof value !== 'string') throw new QueryJobValidationError(`invalid_${field}`)
|
|
216
|
+
if (value.length > max) throw new QueryJobValidationError(`${field}_too_large`)
|
|
217
|
+
const cleaned = value.replace(CONTROL_RE, '')
|
|
218
|
+
if (!allowEmpty && !cleaned.trim()) throw new QueryJobValidationError(`invalid_${field}`)
|
|
219
|
+
return cleaned
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Parse untrusted admission input into the only request shape allowed in the
|
|
223
|
+
* private journal. Unknown keys are dropped before fingerprinting. */
|
|
224
|
+
export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
|
|
225
|
+
if (!raw || typeof raw !== 'object') throw new QueryJobValidationError('invalid_request')
|
|
226
|
+
const input = raw as Record<string, unknown>
|
|
227
|
+
const clientJobId = requiredString(input.clientJobId, 'client_job_id', 36).toLowerCase()
|
|
228
|
+
if (!CLIENT_JOB_ID_RE.test(clientJobId)) throw new QueryJobValidationError('invalid_client_job_id')
|
|
229
|
+
|
|
230
|
+
const generation = Number(input.generation)
|
|
231
|
+
if (!Number.isSafeInteger(generation) || generation < 1) {
|
|
232
|
+
throw new QueryJobValidationError('invalid_generation')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const query = boundedContent(input.query, 'query', QUERY_JOB_LIMITS.promptChars, true)
|
|
236
|
+
const sessionId = requiredString(input.sessionId, 'session_id', 128)
|
|
237
|
+
if (!SAFE_ID_RE.test(sessionId)) throw new QueryJobValidationError('invalid_session_id')
|
|
238
|
+
|
|
239
|
+
const model = optionalString(input.model, 'model', 64)
|
|
240
|
+
const effort = optionalString(input.effort, 'effort', 32)
|
|
241
|
+
const messageEra = optionalString(input.messageEra, 'message_era', 80)
|
|
242
|
+
const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
|
|
243
|
+
const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
|
|
244
|
+
const globalMsgNum = input.globalMsgNum == null ? undefined : Number(input.globalMsgNum)
|
|
245
|
+
if (globalMsgNum != null && (!Number.isSafeInteger(globalMsgNum) || globalMsgNum < 1)) {
|
|
246
|
+
throw new QueryJobValidationError('invalid_global_msg_num')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let reference: QueryJobPromptReference | undefined
|
|
250
|
+
if (input.reference != null) {
|
|
251
|
+
if (!input.reference || typeof input.reference !== 'object') {
|
|
252
|
+
throw new QueryJobValidationError('invalid_reference')
|
|
253
|
+
}
|
|
254
|
+
const ref = input.reference as Record<string, unknown>
|
|
255
|
+
reference = {
|
|
256
|
+
query: boundedContent(ref.query, 'reference_query', QUERY_JOB_LIMITS.referenceQueryChars),
|
|
257
|
+
response: boundedContent(ref.response, 'reference_response', QUERY_JOB_LIMITS.referenceResponseChars),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const activityToolMode: QueryJobActivityMode = input.activityToolMode === 'off'
|
|
262
|
+
|| input.activityToolMode === 'preview'
|
|
263
|
+
? input.activityToolMode
|
|
264
|
+
: 'status'
|
|
265
|
+
|
|
266
|
+
const attachmentIds = parseMediaIdList(input.attachmentIds)
|
|
267
|
+
const attachmentRefs = parseMediaAttachmentRefs(input.attachmentRefs ?? input.attachments)
|
|
268
|
+
if (!query.trim() && attachmentIds.length === 0 && attachmentRefs.length === 0) {
|
|
269
|
+
throw new QueryJobValidationError('query_or_attachment_required')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
clientJobId,
|
|
274
|
+
generation,
|
|
275
|
+
query,
|
|
276
|
+
sessionId,
|
|
277
|
+
...(model ? { model } : {}),
|
|
278
|
+
...(effort ? { effort } : {}),
|
|
279
|
+
...(messageEra ? { messageEra } : {}),
|
|
280
|
+
...(globalMsgNum ? { globalMsgNum } : {}),
|
|
281
|
+
...(reference ? { reference } : {}),
|
|
282
|
+
...(handoffCode ? { handoffCode } : {}),
|
|
283
|
+
...(input.handoffLatest === true ? { handoffLatest: true } : {}),
|
|
284
|
+
...(clientQueueItemId ? { clientQueueItemId } : {}),
|
|
285
|
+
attachmentIds,
|
|
286
|
+
attachmentRefs,
|
|
287
|
+
activityToolMode,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function canonical(value: unknown): unknown {
|
|
292
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
293
|
+
if (!value || typeof value !== 'object') return value
|
|
294
|
+
const record = value as Record<string, unknown>
|
|
295
|
+
return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonical(record[key])]))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function requestFingerprint(request: QueryJobRequest): string {
|
|
299
|
+
return createHash('sha256').update(JSON.stringify(canonical(request))).digest('hex')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function isTerminalQueryJobStatus(status: QueryJobStatus): status is QueryJobTerminalStatus {
|
|
303
|
+
return status === 'completed' || status === 'failed' || status === 'canceled' || status === 'interrupted'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function boundedText(value: unknown, max: number): { text: string; truncated: boolean } {
|
|
307
|
+
const text = typeof value === 'string' ? value.replace(CONTROL_RE, '') : String(value ?? '')
|
|
308
|
+
return text.length <= max
|
|
309
|
+
? { text, truncated: false }
|
|
310
|
+
: { text: text.slice(0, max), truncated: true }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function parseQueryJobOutputImageStats(raw: unknown): QueryJobOutputImageStats | undefined {
|
|
314
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
315
|
+
const value = raw as Record<string, unknown>
|
|
316
|
+
const counts = [value.published, value.attached, value.rejected]
|
|
317
|
+
if (!counts.every(count => Number.isSafeInteger(count) && Number(count) >= 0 && Number(count) <= 1_000)) {
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
const [published, attached, rejected] = counts as number[]
|
|
321
|
+
if (attached > published || rejected > published || attached + rejected > published) return undefined
|
|
322
|
+
return { published, attached, rejected }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Second-line redaction even for bridge-produced "safe" activity. This is
|
|
326
|
+
* intentionally conservative: replay never needs a credential or local path. */
|
|
327
|
+
export function sanitizeQueryJobActivity(value: unknown): { text: string; truncated: boolean } {
|
|
328
|
+
let text = typeof value === 'string' ? value : String(value ?? '')
|
|
329
|
+
text = text.replace(CONTROL_RE, ' ')
|
|
330
|
+
for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[redacted]')
|
|
331
|
+
for (const pattern of PATH_PATTERNS) text = text.replace(pattern, '[path]')
|
|
332
|
+
text = text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
|
333
|
+
return boundedText(text || 'Processing...', QUERY_JOB_LIMITS.activityChars)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function normalizeQueryJobError(error: unknown, fallbackCode = 'query_job_failed'): QueryJobError {
|
|
337
|
+
const candidate = error && typeof error === 'object' ? error as Record<string, unknown> : {}
|
|
338
|
+
const rawCode = typeof candidate.code === 'string' ? candidate.code : fallbackCode
|
|
339
|
+
const code = /^[a-z0-9_.-]{1,80}$/i.test(rawCode) ? rawCode : fallbackCode
|
|
340
|
+
const rawMessage = error instanceof Error ? error.message
|
|
341
|
+
: typeof candidate.message === 'string' ? candidate.message
|
|
342
|
+
: typeof error === 'string' ? error : fallbackCode
|
|
343
|
+
const safe = sanitizeQueryJobActivity(rawMessage)
|
|
344
|
+
return {
|
|
345
|
+
code,
|
|
346
|
+
message: safe.text,
|
|
347
|
+
...(candidate.retryable === true ? { retryable: true } : {}),
|
|
348
|
+
...(typeof candidate.retryAfterMs === 'number' && Number.isFinite(candidate.retryAfterMs)
|
|
349
|
+
? { retryAfterMs: Math.max(0, Math.ceil(candidate.retryAfterMs)) } : {}),
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function parsePositiveInteger(value: unknown): number | undefined {
|
|
354
|
+
if (value == null || value === '') return undefined
|
|
355
|
+
const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
|
|
356
|
+
return typeof parsed === 'number' && Number.isSafeInteger(parsed) && parsed >= 0
|
|
357
|
+
? parsed : undefined
|
|
358
|
+
}
|
|
@@ -80,6 +80,7 @@ export interface RunOutputImageCollectionStats {
|
|
|
80
80
|
|
|
81
81
|
export const RUN_OUTPUT_IMAGE_DIR_PREFIX = 'cos-glasses-output-images-'
|
|
82
82
|
export const RUN_OUTPUT_IMAGE_STALE_MS = 2 * 60 * 60_000
|
|
83
|
+
export const RUN_OUTPUT_IMAGE_COLLECTION_TIMEOUT_MS = 2 * 60_000
|
|
83
84
|
const MANIFEST_MAX_BYTES = 64 * 1024
|
|
84
85
|
export const RUN_OUTPUT_IMAGE_COLLECTION_CONCURRENCY = 2
|
|
85
86
|
const ASSOCIATION_ATTEMPTS = 2
|
|
@@ -92,6 +93,49 @@ const LABELS: Record<OutputImageProvenance, string> = {
|
|
|
92
93
|
}
|
|
93
94
|
const HELPER_PATH = resolve(import.meta.dirname, '..', 'bin', 'cos-output-image-publisher.mjs')
|
|
94
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Keep answer post-processing bounded independently of provider generation.
|
|
98
|
+
* The answer is already durable when bridges call this helper, so an image
|
|
99
|
+
* collector stall must not retain the session lease or strand finalization.
|
|
100
|
+
*/
|
|
101
|
+
export async function collectRunOutputImagesBounded(
|
|
102
|
+
publisher: Pick<RunOutputImagePublisher, 'collect'>,
|
|
103
|
+
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
104
|
+
): Promise<MediaAttachmentRef[]> {
|
|
105
|
+
const timeoutMs = Math.max(1, options.timeoutMs ?? RUN_OUTPUT_IMAGE_COLLECTION_TIMEOUT_MS)
|
|
106
|
+
const signal = options.signal
|
|
107
|
+
if (signal?.aborted) {
|
|
108
|
+
throw signal.reason instanceof Error
|
|
109
|
+
? signal.reason
|
|
110
|
+
: Object.assign(new Error('Output image collection aborted.'), { code: 'output_image_collection_aborted' })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let timer: NodeJS.Timeout | undefined
|
|
114
|
+
let abortHandler: (() => void) | undefined
|
|
115
|
+
const guard = new Promise<never>((_resolve, reject) => {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
reject(Object.assign(
|
|
118
|
+
new Error('Output image collection exceeded its post-answer deadline.'),
|
|
119
|
+
{ code: 'output_image_collection_timeout' },
|
|
120
|
+
))
|
|
121
|
+
}, timeoutMs)
|
|
122
|
+
timer.unref?.()
|
|
123
|
+
if (signal) {
|
|
124
|
+
abortHandler = () => reject(signal.reason instanceof Error
|
|
125
|
+
? signal.reason
|
|
126
|
+
: Object.assign(new Error('Output image collection aborted.'), { code: 'output_image_collection_aborted' }))
|
|
127
|
+
signal.addEventListener('abort', abortHandler, { once: true })
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
return await Promise.race([publisher.collect(), guard])
|
|
133
|
+
} finally {
|
|
134
|
+
if (timer) clearTimeout(timer)
|
|
135
|
+
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
95
139
|
interface ManifestPublishEntry {
|
|
96
140
|
v: 1
|
|
97
141
|
type: 'publish'
|
package/server/routes/health.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve } from 'node:path'
|
|
|
5
5
|
import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
|
|
6
6
|
import { serverMetrics } from '../lib/server-metrics.js'
|
|
7
7
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
8
|
+
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
8
9
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
9
10
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
10
11
|
import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
|
|
@@ -16,9 +17,40 @@ import {
|
|
|
16
17
|
} from '../lib/codex-model-catalog.js'
|
|
17
18
|
import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
18
19
|
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
20
|
+
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
21
|
+
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
19
22
|
|
|
20
23
|
export const healthRouter = Router()
|
|
21
24
|
|
|
25
|
+
function durableQueryJobStatus() {
|
|
26
|
+
const configured = durableQueryJobsCapability()
|
|
27
|
+
const runtime = getQueryJobRuntimeHealth()
|
|
28
|
+
return {
|
|
29
|
+
configured: configured.enabled,
|
|
30
|
+
enabled: configured.enabled
|
|
31
|
+
&& runtime.store.state === 'ready'
|
|
32
|
+
&& !runtime.shuttingDown,
|
|
33
|
+
protocolVersion: configured.protocolVersion,
|
|
34
|
+
activeRuns: runtime.activeRuns,
|
|
35
|
+
shuttingDown: runtime.shuttingDown,
|
|
36
|
+
callbackPersistenceFailures: runtime.callbackPersistenceFailures,
|
|
37
|
+
terminalProjectionFailures: runtime.terminalProjectionFailures,
|
|
38
|
+
store: {
|
|
39
|
+
state: runtime.store.state,
|
|
40
|
+
hydratedJobs: runtime.store.hydratedJobs,
|
|
41
|
+
retainedIdentities: runtime.store.retainedIdentities,
|
|
42
|
+
subscribers: runtime.store.subscribers,
|
|
43
|
+
malformedRows: runtime.store.malformedRows,
|
|
44
|
+
journalFailures: runtime.store.journalFailures,
|
|
45
|
+
interruptedOnBoot: runtime.store.interruptedOnBoot,
|
|
46
|
+
lastErrorCode: runtime.store.lastErrorCode,
|
|
47
|
+
lastSuccessfulWriteAt: runtime.store.lastSuccessfulWriteAt,
|
|
48
|
+
rootFingerprint: runtime.store.rootFingerprint,
|
|
49
|
+
counts: runtime.store.counts,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
healthRouter.get('/health', async (_req, res) => {
|
|
23
55
|
const checks: Record<string, string | number> = {
|
|
24
56
|
status: 'ok',
|
|
@@ -106,6 +138,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
106
138
|
// restart. The nested voice block also exposes the source so future wizard
|
|
107
139
|
// work can decide whether to prompt for a key.
|
|
108
140
|
const keyStatus = getKeyStatus()
|
|
141
|
+
const durableJobs = durableQueryJobStatus()
|
|
142
|
+
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
109
143
|
const features = {
|
|
110
144
|
claude: claudeAvailable,
|
|
111
145
|
codex: codexAvailable,
|
|
@@ -117,6 +151,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
117
151
|
iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
|
|
118
152
|
mediaProcessingReady: await isMediaProcessingReady(),
|
|
119
153
|
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|
|
154
|
+
durableQueryJobs: durableJobs.enabled,
|
|
155
|
+
durableQueryJobsProtocol: durableJobs.protocolVersion,
|
|
156
|
+
localFirstMeetings: localFirstMeetings !== null,
|
|
120
157
|
}
|
|
121
158
|
const voice = {
|
|
122
159
|
hasKey: keyStatus.hasKey,
|
|
@@ -129,14 +166,43 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
129
166
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
130
167
|
|
|
131
168
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
132
|
-
res.json({
|
|
169
|
+
res.json({
|
|
170
|
+
...checks,
|
|
171
|
+
features,
|
|
172
|
+
voice,
|
|
173
|
+
whisper_health,
|
|
174
|
+
openai_whisper_budget,
|
|
175
|
+
codex_models,
|
|
176
|
+
capabilities: localFirstMeetings ? { localFirstMeetings } : {},
|
|
177
|
+
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
178
|
+
// Publish capability only; job counts, retention identities, subscriber
|
|
179
|
+
// counts, and the storage fingerprint remain internal.
|
|
180
|
+
durable_query_jobs: {
|
|
181
|
+
configured: durableJobs.configured,
|
|
182
|
+
enabled: durableJobs.enabled,
|
|
183
|
+
protocolVersion: durableJobs.protocolVersion,
|
|
184
|
+
state: durableJobs.store.state,
|
|
185
|
+
},
|
|
186
|
+
})
|
|
133
187
|
})
|
|
134
188
|
|
|
135
189
|
// Stable app slots backed by Codex's live model/list catalog. This route is
|
|
136
190
|
// authenticated by the global /api middleware; ?refresh=1 forces discovery.
|
|
137
191
|
healthRouter.get('/models', async (req, res) => {
|
|
138
192
|
const catalog = await getCodexModelCatalog(req.query.refresh === '1')
|
|
139
|
-
|
|
193
|
+
const durableJobs = durableQueryJobStatus()
|
|
194
|
+
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
195
|
+
res.json({
|
|
196
|
+
...catalog,
|
|
197
|
+
serverInstanceId: getServerInstanceId(),
|
|
198
|
+
capabilities: {
|
|
199
|
+
durableQueryJobs: {
|
|
200
|
+
enabled: durableJobs.enabled,
|
|
201
|
+
protocolVersion: durableJobs.protocolVersion,
|
|
202
|
+
},
|
|
203
|
+
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
204
|
+
},
|
|
205
|
+
})
|
|
140
206
|
})
|
|
141
207
|
|
|
142
208
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|
package/server/routes/meeting.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
getSessionProviderCandidates,
|
|
32
32
|
getSessionStartTime,
|
|
33
33
|
getSessionTranscript,
|
|
34
|
+
getMeetingSessionStatus,
|
|
34
35
|
hasSessionAudio,
|
|
35
36
|
moveSessionAudioToPending,
|
|
36
37
|
type IndexedTranscriptChunk,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
type TranscriptChunk,
|
|
39
40
|
type TranscriptGapReport,
|
|
40
41
|
} from './transcribe-stream.js'
|
|
42
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
41
43
|
|
|
42
44
|
interface MeetingSessionSource {
|
|
43
45
|
getTranscript(sessionId: string): string | null
|
|
@@ -98,6 +100,8 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
|
|
|
98
100
|
? Math.floor(integrity.completeness * 1_000) / 10
|
|
99
101
|
: 100
|
|
100
102
|
return {
|
|
103
|
+
receiptVersion: 1,
|
|
104
|
+
serverInstanceId: getServerInstanceId(),
|
|
101
105
|
saved: true,
|
|
102
106
|
// Keep the build199 string field without leaking an absolute host path.
|
|
103
107
|
filepath: `recordings/${saved.month}/${saved.filename}`,
|
|
@@ -125,6 +129,33 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
125
129
|
const router = Router()
|
|
126
130
|
const savingSessions = new Set<string>()
|
|
127
131
|
|
|
132
|
+
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
133
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
134
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
135
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const serverInstanceId = getServerInstanceId()
|
|
139
|
+
if (!serverInstanceId) {
|
|
140
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const saved = store.findBySessionId(sessionId)
|
|
144
|
+
const live = getMeetingSessionStatus(sessionId)
|
|
145
|
+
res.set('Cache-Control', 'private, no-store')
|
|
146
|
+
res.json({
|
|
147
|
+
sessionId,
|
|
148
|
+
state: saved ? 'saved' : live.state,
|
|
149
|
+
serverInstanceId,
|
|
150
|
+
receivedRanges: live.receivedRanges,
|
|
151
|
+
receivedCount: live.receivedCount,
|
|
152
|
+
maxChunkIndex: live.maxChunkIndex,
|
|
153
|
+
lastActivityAt: live.lastActivityAt,
|
|
154
|
+
retainedUntil: saved ? null : live.retainedUntil,
|
|
155
|
+
saveReceipt: saved ? publicSaveResponse(saved) : null,
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
128
159
|
router.post('/meeting/save', async (req, res) => {
|
|
129
160
|
let lockedSessionId: string | null = null
|
|
130
161
|
try {
|