@gotcos/glasses-server 6.9.0 → 6.10.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 +24 -0
- package/README.md +10 -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/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 +62 -2
- package/server/routes/query-jobs.ts +294 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { acquireModelSessionRunLock, callModelStreaming } from './model-router.js'
|
|
4
|
+
import { emitDisplay } from './display-bus.js'
|
|
5
|
+
import { resolveQueryAttachments } from './query-attachments.js'
|
|
6
|
+
import { getMediaStore } from './media-store.js'
|
|
7
|
+
import { dataPath } from './data-dir.js'
|
|
8
|
+
import { durableQueryJobsEnabled } from './query-job-feature.js'
|
|
9
|
+
import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
|
|
10
|
+
import { QueryJobStore } from './query-job-store.js'
|
|
11
|
+
import {
|
|
12
|
+
isCodexModel,
|
|
13
|
+
normalizeEffortPreference,
|
|
14
|
+
normalizeModelPreference,
|
|
15
|
+
type ModelPreference,
|
|
16
|
+
} from '../../shared/model-preference.js'
|
|
17
|
+
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
18
|
+
import {
|
|
19
|
+
findExchangesByJobIdentity,
|
|
20
|
+
flushConversationToDisk,
|
|
21
|
+
reconcileExchangeByJobIdentity,
|
|
22
|
+
removeExchangesByJobIdentity,
|
|
23
|
+
} from './conversation.js'
|
|
24
|
+
import {
|
|
25
|
+
isTerminalQueryJobStatus,
|
|
26
|
+
type QueryJobRequest,
|
|
27
|
+
type QueryJobSnapshot,
|
|
28
|
+
} from './query-job-types.js'
|
|
29
|
+
|
|
30
|
+
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
31
|
+
WebSearch: 'Searching web...',
|
|
32
|
+
WebFetch: 'Reading page...',
|
|
33
|
+
Read: 'Analyzing photo...',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve image bytes/ids before returning 202. The journal receives only
|
|
37
|
+
* validated refs and ids; base64 bytes and provider-local paths are dropped by
|
|
38
|
+
* the strict QueryJobRequest parser before persistence. */
|
|
39
|
+
export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
|
|
40
|
+
if (!raw || typeof raw !== 'object') return raw
|
|
41
|
+
const input = raw as Record<string, unknown>
|
|
42
|
+
const resolved = await resolveQueryAttachments(input)
|
|
43
|
+
return {
|
|
44
|
+
...input,
|
|
45
|
+
attachmentIds: resolved.ids,
|
|
46
|
+
attachmentRefs: resolved.refs,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function providerFor(model: ModelPreference): 'claude' | 'codex' {
|
|
51
|
+
return isCodexModel(model) ? 'codex' : 'claude'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Project the authoritative terminal journal into the derived conversation
|
|
55
|
+
* cache. Journaled request/response text always wins over bridge-written
|
|
56
|
+
* partial rows; validated media refs may be merged because output media can
|
|
57
|
+
* finish immediately before a crash. Exact provenance collapses duplicates. */
|
|
58
|
+
async function projectPublicConversationTerminal(
|
|
59
|
+
job: QueryJobSnapshot,
|
|
60
|
+
request: QueryJobRequest,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
if (!isTerminalQueryJobStatus(job.status)) return
|
|
63
|
+
const identity = { clientJobId: request.clientJobId, generation: request.generation }
|
|
64
|
+
if (job.status !== 'completed') {
|
|
65
|
+
removeExchangesByJobIdentity(request.sessionId, identity)
|
|
66
|
+
flushConversationToDisk()
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const existing = findExchangesByJobIdentity(request.sessionId, identity)
|
|
71
|
+
const existingAssistant = existing.find(exchange => exchange.role === 'assistant')
|
|
72
|
+
const imageCount = request.attachmentRefs.length
|
|
73
|
+
const photoPrefix = imageCount === 1 ? '[Photo]' : imageCount > 1 ? `[${imageCount} Photos]` : ''
|
|
74
|
+
const userContent = photoPrefix ? `${photoPrefix} ${request.query || 'What do you see?'}` : request.query
|
|
75
|
+
const requestIds = new Set(request.attachmentRefs.map(ref => ref.id))
|
|
76
|
+
const outputAttachments = job.attachments.filter(ref => !requestIds.has(ref.id))
|
|
77
|
+
const existingOutputAttachments = existingAssistant?.attachments?.filter(ref => !requestIds.has(ref.id))
|
|
78
|
+
|
|
79
|
+
reconcileExchangeByJobIdentity(
|
|
80
|
+
request.sessionId,
|
|
81
|
+
identity,
|
|
82
|
+
'user',
|
|
83
|
+
userContent,
|
|
84
|
+
request.globalMsgNum,
|
|
85
|
+
request.attachmentRefs,
|
|
86
|
+
)
|
|
87
|
+
reconcileExchangeByJobIdentity(
|
|
88
|
+
request.sessionId,
|
|
89
|
+
identity,
|
|
90
|
+
'assistant',
|
|
91
|
+
job.response ?? job.partialText,
|
|
92
|
+
request.globalMsgNum,
|
|
93
|
+
mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
|
|
94
|
+
)
|
|
95
|
+
flushConversationToDisk()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callbacks }) => {
|
|
99
|
+
// Resolve ids again at execution time. This closes the admission/execution
|
|
100
|
+
// TOCTOU window without ever putting paths or bytes in the journal.
|
|
101
|
+
const resolvedAttachments = await resolveQueryAttachments({
|
|
102
|
+
attachmentIds: request.attachmentIds,
|
|
103
|
+
clientQueueItemId: request.clientQueueItemId,
|
|
104
|
+
sessionId: request.sessionId,
|
|
105
|
+
})
|
|
106
|
+
const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
|
|
107
|
+
const validModel = normalizeModelPreference(request.model)
|
|
108
|
+
const validEffort = normalizeEffortPreference(request.effort)
|
|
109
|
+
let activeModel = validModel
|
|
110
|
+
|
|
111
|
+
await callModelStreaming(
|
|
112
|
+
request.query,
|
|
113
|
+
request.sessionId,
|
|
114
|
+
{
|
|
115
|
+
onStart: async (model, sessionId, cliSessionId, metadata) => {
|
|
116
|
+
activeModel = model
|
|
117
|
+
const linkage = {
|
|
118
|
+
provider: providerFor(model),
|
|
119
|
+
resolvedModel: model,
|
|
120
|
+
cliSessionId,
|
|
121
|
+
claudeRunId: metadata?.claudeRunId,
|
|
122
|
+
codexRunId: metadata?.codexRunId,
|
|
123
|
+
codexThreadId: metadata?.codexThreadId,
|
|
124
|
+
} as const
|
|
125
|
+
await callbacks.onStart({ sessionId, ...linkage })
|
|
126
|
+
emitDisplay({ type: 'start', data: {
|
|
127
|
+
jobId,
|
|
128
|
+
clientJobId: request.clientJobId,
|
|
129
|
+
generation: request.generation,
|
|
130
|
+
turnId,
|
|
131
|
+
messageEra: request.messageEra,
|
|
132
|
+
globalMsgNum: request.globalMsgNum,
|
|
133
|
+
model,
|
|
134
|
+
sessionId,
|
|
135
|
+
cliSessionId,
|
|
136
|
+
...metadata,
|
|
137
|
+
} })
|
|
138
|
+
},
|
|
139
|
+
onProviderProcess: metadata => callbacks.onProviderProcess({
|
|
140
|
+
provider: metadata.provider,
|
|
141
|
+
...(activeModel ? { resolvedModel: activeModel } : {}),
|
|
142
|
+
...(metadata.provider === 'claude'
|
|
143
|
+
? { claudeRunId: metadata.runId }
|
|
144
|
+
: { codexRunId: metadata.runId }),
|
|
145
|
+
}),
|
|
146
|
+
onChunk: text => { callbacks.onChunk(text) },
|
|
147
|
+
onToolStatus: toolName => {
|
|
148
|
+
const message = request.activityToolMode === 'off'
|
|
149
|
+
? 'Processing...'
|
|
150
|
+
: TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
|
|
151
|
+
callbacks.onToolStatus(message)
|
|
152
|
+
},
|
|
153
|
+
...(request.activityToolMode === 'preview' ? {
|
|
154
|
+
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => callbacks.onActivityLine(line),
|
|
155
|
+
} : {}),
|
|
156
|
+
onAnswerReady: text => callbacks.onAnswerReady(text, {
|
|
157
|
+
...(activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
|
|
158
|
+
}),
|
|
159
|
+
onDone: async (fullText, model, cliSessionId, metadata) => {
|
|
160
|
+
const attachments = mergeMediaAttachmentRefs(
|
|
161
|
+
resolvedAttachments.refs,
|
|
162
|
+
metadata?.outputAttachments,
|
|
163
|
+
)
|
|
164
|
+
const linkage = {
|
|
165
|
+
provider: providerFor(model),
|
|
166
|
+
resolvedModel: model,
|
|
167
|
+
cliSessionId,
|
|
168
|
+
claudeRunId: metadata?.claudeRunId,
|
|
169
|
+
codexRunId: metadata?.codexRunId,
|
|
170
|
+
codexThreadId: metadata?.codexThreadId,
|
|
171
|
+
} as const
|
|
172
|
+
// Publish compatibility completion only after the durable terminal is
|
|
173
|
+
// fsynced. Display subscribers can disappear without owning this job.
|
|
174
|
+
const terminalOwned = await callbacks.onDone({
|
|
175
|
+
text: fullText,
|
|
176
|
+
attachments,
|
|
177
|
+
outputImageStats: metadata?.outputImageStats,
|
|
178
|
+
...linkage,
|
|
179
|
+
})
|
|
180
|
+
if (!terminalOwned) return
|
|
181
|
+
if (resolvedAttachments.ids.length > 0) {
|
|
182
|
+
await getMediaStore().associate(resolvedAttachments.ids, {
|
|
183
|
+
sessionId: request.sessionId,
|
|
184
|
+
...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
|
|
185
|
+
}).catch(error => console.error('[query-jobs] attachment association failed:', error))
|
|
186
|
+
}
|
|
187
|
+
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
188
|
+
emitDisplay({ type: 'done', data: {
|
|
189
|
+
jobId,
|
|
190
|
+
clientJobId: request.clientJobId,
|
|
191
|
+
generation: request.generation,
|
|
192
|
+
turnId,
|
|
193
|
+
messageEra: request.messageEra,
|
|
194
|
+
globalMsgNum: request.globalMsgNum,
|
|
195
|
+
text: fullText,
|
|
196
|
+
sessionId: request.sessionId,
|
|
197
|
+
model,
|
|
198
|
+
cliSessionId,
|
|
199
|
+
...runMetadata,
|
|
200
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
201
|
+
} })
|
|
202
|
+
},
|
|
203
|
+
onError: async error => {
|
|
204
|
+
const terminalOwned = await callbacks.onError(error)
|
|
205
|
+
if (!terminalOwned) return
|
|
206
|
+
emitDisplay({ type: 'error', data: {
|
|
207
|
+
jobId,
|
|
208
|
+
clientJobId: request.clientJobId,
|
|
209
|
+
generation: request.generation,
|
|
210
|
+
turnId,
|
|
211
|
+
messageEra: request.messageEra,
|
|
212
|
+
globalMsgNum: request.globalMsgNum,
|
|
213
|
+
error,
|
|
214
|
+
} })
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
validModel,
|
|
218
|
+
imageInputs,
|
|
219
|
+
request.reference,
|
|
220
|
+
request.globalMsgNum,
|
|
221
|
+
{
|
|
222
|
+
abortSignal: signal,
|
|
223
|
+
effort: validEffort,
|
|
224
|
+
clientJobId: request.clientJobId,
|
|
225
|
+
generation: request.generation,
|
|
226
|
+
sessionLockHeld: true,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const configuredRoot = process.env.COS_QUERY_JOB_DIR?.trim()
|
|
232
|
+
const queryJobRoot = configuredRoot ? resolve(configuredRoot) : dataPath('query-jobs')
|
|
233
|
+
|
|
234
|
+
export const queryJobStore = new QueryJobStore({
|
|
235
|
+
root: queryJobRoot,
|
|
236
|
+
bootId: randomUUID(),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner, {
|
|
240
|
+
projectTerminal: projectPublicConversationTerminal,
|
|
241
|
+
acquireSessionLock: acquireModelSessionRunLock,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
export function initQueryJobRuntime() {
|
|
245
|
+
if (!durableQueryJobsEnabled()) return Promise.resolve(queryJobCoordinator.getHealth())
|
|
246
|
+
return queryJobCoordinator.init()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function shutdownQueryJobRuntime(reason = 'server_shutdown') {
|
|
250
|
+
return queryJobCoordinator.shutdown(reason)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function getQueryJobRuntimeHealth() {
|
|
254
|
+
return queryJobCoordinator.getHealth()
|
|
255
|
+
}
|