@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,294 @@
|
|
|
1
|
+
import { Router, type Response } from 'express'
|
|
2
|
+
import { durableQueryJobsEnabled } from '../lib/query-job-feature.js'
|
|
3
|
+
import {
|
|
4
|
+
QueryJobCoordinator,
|
|
5
|
+
QueryJobCoordinatorError,
|
|
6
|
+
queryJobErrorCode,
|
|
7
|
+
} from '../lib/query-job-coordinator.js'
|
|
8
|
+
import {
|
|
9
|
+
QueryJobAnswerCommittingError,
|
|
10
|
+
QueryJobActiveGenerationError,
|
|
11
|
+
QueryJobGenerationMismatchError,
|
|
12
|
+
QueryJobGenerationOrderError,
|
|
13
|
+
QueryJobIdentityConflictError,
|
|
14
|
+
QueryJobNotFoundError,
|
|
15
|
+
QueryJobNotTerminalError,
|
|
16
|
+
QueryJobPersistenceError,
|
|
17
|
+
QueryJobProviderOrphanFenceError,
|
|
18
|
+
QueryJobStoreError,
|
|
19
|
+
} from '../lib/query-job-store.js'
|
|
20
|
+
import {
|
|
21
|
+
isTerminalQueryJobStatus,
|
|
22
|
+
normalizeQueryJobError,
|
|
23
|
+
parsePositiveInteger,
|
|
24
|
+
QueryJobValidationError,
|
|
25
|
+
type QueryJobEvent,
|
|
26
|
+
type QueryJobSnapshot,
|
|
27
|
+
} from '../lib/query-job-types.js'
|
|
28
|
+
|
|
29
|
+
const 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}$/i
|
|
30
|
+
|
|
31
|
+
export interface QueryJobsRouterOptions {
|
|
32
|
+
enabled?: () => boolean
|
|
33
|
+
heartbeatMs?: number
|
|
34
|
+
prepareAdmission?: (raw: unknown) => unknown | Promise<unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface WireError {
|
|
38
|
+
error: { code: string; message: string; retryable?: boolean; retryAfterMs?: number }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function wireError(error: unknown): { status: number; body: WireError } {
|
|
42
|
+
let status = 500
|
|
43
|
+
const explicitStatus = Number((error as { status?: unknown })?.status)
|
|
44
|
+
if (Number.isInteger(explicitStatus) && explicitStatus >= 400 && explicitStatus <= 599) status = explicitStatus
|
|
45
|
+
else if (error instanceof QueryJobValidationError) status = 400
|
|
46
|
+
else if (error instanceof QueryJobNotFoundError) status = 404
|
|
47
|
+
else if (error instanceof QueryJobGenerationMismatchError
|
|
48
|
+
|| error instanceof QueryJobIdentityConflictError
|
|
49
|
+
|| error instanceof QueryJobActiveGenerationError
|
|
50
|
+
|| error instanceof QueryJobGenerationOrderError
|
|
51
|
+
|| error instanceof QueryJobProviderOrphanFenceError
|
|
52
|
+
|| error instanceof QueryJobAnswerCommittingError
|
|
53
|
+
|| error instanceof QueryJobNotTerminalError) status = 409
|
|
54
|
+
else if (error instanceof QueryJobPersistenceError
|
|
55
|
+
|| error instanceof QueryJobCoordinatorError) status = 503
|
|
56
|
+
else if (error instanceof QueryJobStoreError) status = 409
|
|
57
|
+
const normalized = normalizeQueryJobError(error, queryJobErrorCode(error))
|
|
58
|
+
return { status, body: { error: normalized } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validJobId(raw: unknown): string {
|
|
62
|
+
if (typeof raw !== 'string' || !JOB_ID_RE.test(raw)) throw new QueryJobValidationError('invalid_job_id')
|
|
63
|
+
return raw.toLowerCase()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validClientJobId(raw: unknown): string {
|
|
67
|
+
if (typeof raw !== 'string' || !JOB_ID_RE.test(raw)) {
|
|
68
|
+
throw new QueryJobValidationError('invalid_client_job_id')
|
|
69
|
+
}
|
|
70
|
+
return raw.toLowerCase()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requiredGeneration(raw: unknown): number {
|
|
74
|
+
const value = parsePositiveInteger(raw)
|
|
75
|
+
if (value == null || value < 1) throw new QueryJobValidationError('invalid_generation')
|
|
76
|
+
return value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cursor(raw: unknown): number {
|
|
80
|
+
if (raw == null || raw === '') return 0
|
|
81
|
+
const value = parsePositiveInteger(raw)
|
|
82
|
+
if (value == null) throw new QueryJobValidationError('invalid_event_cursor')
|
|
83
|
+
return value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeSse(res: Response, type: string, event: Record<string, unknown>): void {
|
|
87
|
+
const sequence = typeof event.eventSeq === 'number' ? event.eventSeq : undefined
|
|
88
|
+
if (sequence != null) res.write(`id: ${sequence}\n`)
|
|
89
|
+
res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function snapshotEvent(job: QueryJobSnapshot, reason: string): Record<string, unknown> {
|
|
93
|
+
return {
|
|
94
|
+
type: 'snapshot',
|
|
95
|
+
eventSeq: job.eventSeq,
|
|
96
|
+
jobId: job.jobId,
|
|
97
|
+
clientJobId: job.clientJobId,
|
|
98
|
+
generation: job.generation,
|
|
99
|
+
status: job.status,
|
|
100
|
+
at: job.updatedAt,
|
|
101
|
+
data: { reason, job },
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createQueryJobsRouter(
|
|
106
|
+
coordinator: QueryJobCoordinator,
|
|
107
|
+
options: QueryJobsRouterOptions = {},
|
|
108
|
+
): Router {
|
|
109
|
+
const router = Router()
|
|
110
|
+
const enabled = options.enabled ?? durableQueryJobsEnabled
|
|
111
|
+
const heartbeatMs = Math.max(1_000, options.heartbeatMs ?? 15_000)
|
|
112
|
+
|
|
113
|
+
router.post('/query-jobs', async (req, res) => {
|
|
114
|
+
if (!enabled()) {
|
|
115
|
+
return res.status(404).json({
|
|
116
|
+
error: {
|
|
117
|
+
code: 'durable_query_jobs_disabled',
|
|
118
|
+
message: 'Durable query jobs are not enabled on this server.',
|
|
119
|
+
},
|
|
120
|
+
} satisfies WireError)
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const prepared = options.prepareAdmission ? await options.prepareAdmission(req.body) : req.body
|
|
124
|
+
const admission = await coordinator.submit(prepared)
|
|
125
|
+
return res.status(202).json({ job: admission.job })
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const wire = wireError(error)
|
|
128
|
+
return res.status(wire.status).json(wire.body)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Lost-202 recovery is keyed by the immutable client identity. Keep this
|
|
133
|
+
// route explicit and ahead of the dynamic job lookup for future router
|
|
134
|
+
// changes that may broaden the latter's matcher.
|
|
135
|
+
router.get('/query-jobs/by-client/:clientJobId', async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
const clientJobId = validClientJobId(req.params.clientJobId)
|
|
138
|
+
const generation = requiredGeneration(req.query.generation)
|
|
139
|
+
const job = await coordinator.getByClientGeneration(clientJobId, generation)
|
|
140
|
+
if (!job) throw new QueryJobNotFoundError(`${clientJobId}:${generation}`)
|
|
141
|
+
return res.json({ job })
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const wire = wireError(error)
|
|
144
|
+
return res.status(wire.status).json(wire.body)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
router.get('/query-jobs/:jobId', async (req, res) => {
|
|
149
|
+
try {
|
|
150
|
+
const jobId = validJobId(req.params.jobId)
|
|
151
|
+
const generation = requiredGeneration(req.query.generation)
|
|
152
|
+
const job = await coordinator.getSnapshot(jobId, generation)
|
|
153
|
+
return res.json({ job })
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const wire = wireError(error)
|
|
156
|
+
return res.status(wire.status).json(wire.body)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
router.get('/query-jobs/:jobId/events', async (req, res) => {
|
|
161
|
+
let unsubscribe: (() => void) | undefined
|
|
162
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
163
|
+
let closed = false
|
|
164
|
+
let streamReady = false
|
|
165
|
+
let sentSeq = -1
|
|
166
|
+
const pendingLive: QueryJobEvent[] = []
|
|
167
|
+
const cleanup = () => {
|
|
168
|
+
if (closed) return
|
|
169
|
+
closed = true
|
|
170
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
171
|
+
unsubscribe?.()
|
|
172
|
+
}
|
|
173
|
+
const finish = () => {
|
|
174
|
+
cleanup()
|
|
175
|
+
if (!res.writableEnded) res.end()
|
|
176
|
+
}
|
|
177
|
+
// Register before subscribe(): a client can background/close while the
|
|
178
|
+
// store is still preparing replay. The returned live listener must then
|
|
179
|
+
// be released as soon as its delayed subscription arrives.
|
|
180
|
+
res.on('close', cleanup)
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const jobId = validJobId(req.params.jobId)
|
|
184
|
+
const generation = requiredGeneration(req.query.generation)
|
|
185
|
+
const after = cursor(req.query.after)
|
|
186
|
+
sentSeq = after
|
|
187
|
+
|
|
188
|
+
const subscription = await coordinator.subscribe(jobId, generation, after, (event: QueryJobEvent) => {
|
|
189
|
+
// subscribe() registers its live listener before returning the replay
|
|
190
|
+
// snapshot so no append can fall into a gap. A very fast provider may
|
|
191
|
+
// therefore publish while this route is still installing SSE headers.
|
|
192
|
+
// Buffer that narrow window instead of allowing res.write() to commit
|
|
193
|
+
// implicit non-SSE headers before writeHead() below.
|
|
194
|
+
if (!streamReady) {
|
|
195
|
+
pendingLive.push(event)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (closed || event.eventSeq <= sentSeq) return
|
|
199
|
+
sentSeq = event.eventSeq
|
|
200
|
+
writeSse(res, event.type, event as unknown as Record<string, unknown>)
|
|
201
|
+
if (isTerminalQueryJobStatus(event.status)) finish()
|
|
202
|
+
})
|
|
203
|
+
unsubscribe = subscription.unsubscribe
|
|
204
|
+
if (closed) {
|
|
205
|
+
unsubscribe()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
res.writeHead(200, {
|
|
210
|
+
'Content-Type': 'text/event-stream',
|
|
211
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
212
|
+
'Connection': 'keep-alive',
|
|
213
|
+
'X-Accel-Buffering': 'no',
|
|
214
|
+
'Access-Control-Allow-Origin': '*',
|
|
215
|
+
})
|
|
216
|
+
res.flushHeaders()
|
|
217
|
+
res.write(': keepalive\n\n')
|
|
218
|
+
|
|
219
|
+
const { replay } = subscription
|
|
220
|
+
if (replay.gap) {
|
|
221
|
+
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, replay.reason ?? 'replay_gap'))
|
|
222
|
+
sentSeq = replay.snapshot.eventSeq
|
|
223
|
+
} else {
|
|
224
|
+
for (const event of replay.events) {
|
|
225
|
+
if (event.eventSeq <= sentSeq) continue
|
|
226
|
+
sentSeq = event.eventSeq
|
|
227
|
+
writeSse(res, event.type, event as unknown as Record<string, unknown>)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const replayTerminal = replay.events.some(event => isTerminalQueryJobStatus(event.status))
|
|
232
|
+
if (isTerminalQueryJobStatus(replay.snapshot.status) && !replayTerminal && !replay.gap) {
|
|
233
|
+
writeSse(res, 'snapshot', snapshotEvent(replay.snapshot, 'terminal_snapshot'))
|
|
234
|
+
sentSeq = replay.snapshot.eventSeq
|
|
235
|
+
}
|
|
236
|
+
if (isTerminalQueryJobStatus(replay.snapshot.status)) {
|
|
237
|
+
finish()
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
streamReady = true
|
|
242
|
+
for (const event of pendingLive) {
|
|
243
|
+
if (closed || event.eventSeq <= sentSeq) continue
|
|
244
|
+
sentSeq = event.eventSeq
|
|
245
|
+
writeSse(res, event.type, event as unknown as Record<string, unknown>)
|
|
246
|
+
if (isTerminalQueryJobStatus(event.status)) {
|
|
247
|
+
finish()
|
|
248
|
+
break
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
pendingLive.length = 0
|
|
252
|
+
if (closed) return
|
|
253
|
+
|
|
254
|
+
heartbeat = setInterval(() => {
|
|
255
|
+
if (!closed && !res.writableEnded) res.write(': keepalive\n\n')
|
|
256
|
+
}, heartbeatMs)
|
|
257
|
+
heartbeat.unref?.()
|
|
258
|
+
} catch (error) {
|
|
259
|
+
cleanup()
|
|
260
|
+
if (res.headersSent) {
|
|
261
|
+
if (!res.writableEnded) res.end()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
const wire = wireError(error)
|
|
265
|
+
res.status(wire.status).json(wire.body)
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
router.post('/query-jobs/:jobId/cancel', async (req, res) => {
|
|
270
|
+
try {
|
|
271
|
+
const jobId = validJobId(req.params.jobId)
|
|
272
|
+
const generation = requiredGeneration(req.body?.generation)
|
|
273
|
+
const job = await coordinator.cancel(jobId, generation)
|
|
274
|
+
return res.json({ job })
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const wire = wireError(error)
|
|
277
|
+
return res.status(wire.status).json(wire.body)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
router.post('/query-jobs/:jobId/ack', async (req, res) => {
|
|
282
|
+
try {
|
|
283
|
+
const jobId = validJobId(req.params.jobId)
|
|
284
|
+
const generation = requiredGeneration(req.body?.generation)
|
|
285
|
+
const job = await coordinator.acknowledge(jobId, generation)
|
|
286
|
+
return res.json({ job })
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const wire = wireError(error)
|
|
289
|
+
return res.status(wire.status).json(wire.body)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
return router
|
|
294
|
+
}
|