@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,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
|
@@ -16,9 +16,40 @@ import {
|
|
|
16
16
|
} from '../lib/codex-model-catalog.js'
|
|
17
17
|
import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
18
18
|
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
19
|
+
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
20
|
+
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
19
21
|
|
|
20
22
|
export const healthRouter = Router()
|
|
21
23
|
|
|
24
|
+
function durableQueryJobStatus() {
|
|
25
|
+
const configured = durableQueryJobsCapability()
|
|
26
|
+
const runtime = getQueryJobRuntimeHealth()
|
|
27
|
+
return {
|
|
28
|
+
configured: configured.enabled,
|
|
29
|
+
enabled: configured.enabled
|
|
30
|
+
&& runtime.store.state === 'ready'
|
|
31
|
+
&& !runtime.shuttingDown,
|
|
32
|
+
protocolVersion: configured.protocolVersion,
|
|
33
|
+
activeRuns: runtime.activeRuns,
|
|
34
|
+
shuttingDown: runtime.shuttingDown,
|
|
35
|
+
callbackPersistenceFailures: runtime.callbackPersistenceFailures,
|
|
36
|
+
terminalProjectionFailures: runtime.terminalProjectionFailures,
|
|
37
|
+
store: {
|
|
38
|
+
state: runtime.store.state,
|
|
39
|
+
hydratedJobs: runtime.store.hydratedJobs,
|
|
40
|
+
retainedIdentities: runtime.store.retainedIdentities,
|
|
41
|
+
subscribers: runtime.store.subscribers,
|
|
42
|
+
malformedRows: runtime.store.malformedRows,
|
|
43
|
+
journalFailures: runtime.store.journalFailures,
|
|
44
|
+
interruptedOnBoot: runtime.store.interruptedOnBoot,
|
|
45
|
+
lastErrorCode: runtime.store.lastErrorCode,
|
|
46
|
+
lastSuccessfulWriteAt: runtime.store.lastSuccessfulWriteAt,
|
|
47
|
+
rootFingerprint: runtime.store.rootFingerprint,
|
|
48
|
+
counts: runtime.store.counts,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
healthRouter.get('/health', async (_req, res) => {
|
|
23
54
|
const checks: Record<string, string | number> = {
|
|
24
55
|
status: 'ok',
|
|
@@ -106,6 +137,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
106
137
|
// restart. The nested voice block also exposes the source so future wizard
|
|
107
138
|
// work can decide whether to prompt for a key.
|
|
108
139
|
const keyStatus = getKeyStatus()
|
|
140
|
+
const durableJobs = durableQueryJobStatus()
|
|
109
141
|
const features = {
|
|
110
142
|
claude: claudeAvailable,
|
|
111
143
|
codex: codexAvailable,
|
|
@@ -117,6 +149,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
117
149
|
iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
|
|
118
150
|
mediaProcessingReady: await isMediaProcessingReady(),
|
|
119
151
|
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|
|
152
|
+
durableQueryJobs: durableJobs.enabled,
|
|
153
|
+
durableQueryJobsProtocol: durableJobs.protocolVersion,
|
|
120
154
|
}
|
|
121
155
|
const voice = {
|
|
122
156
|
hasKey: keyStatus.hasKey,
|
|
@@ -129,14 +163,40 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
129
163
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
130
164
|
|
|
131
165
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
132
|
-
res.json({
|
|
166
|
+
res.json({
|
|
167
|
+
...checks,
|
|
168
|
+
features,
|
|
169
|
+
voice,
|
|
170
|
+
whisper_health,
|
|
171
|
+
openai_whisper_budget,
|
|
172
|
+
codex_models,
|
|
173
|
+
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
174
|
+
// Publish capability only; job counts, retention identities, subscriber
|
|
175
|
+
// counts, and the storage fingerprint remain internal.
|
|
176
|
+
durable_query_jobs: {
|
|
177
|
+
configured: durableJobs.configured,
|
|
178
|
+
enabled: durableJobs.enabled,
|
|
179
|
+
protocolVersion: durableJobs.protocolVersion,
|
|
180
|
+
state: durableJobs.store.state,
|
|
181
|
+
},
|
|
182
|
+
})
|
|
133
183
|
})
|
|
134
184
|
|
|
135
185
|
// Stable app slots backed by Codex's live model/list catalog. This route is
|
|
136
186
|
// authenticated by the global /api middleware; ?refresh=1 forces discovery.
|
|
137
187
|
healthRouter.get('/models', async (req, res) => {
|
|
138
188
|
const catalog = await getCodexModelCatalog(req.query.refresh === '1')
|
|
139
|
-
|
|
189
|
+
const durableJobs = durableQueryJobStatus()
|
|
190
|
+
res.json({
|
|
191
|
+
...catalog,
|
|
192
|
+
serverInstanceId: getServerInstanceId(),
|
|
193
|
+
capabilities: {
|
|
194
|
+
durableQueryJobs: {
|
|
195
|
+
enabled: durableJobs.enabled,
|
|
196
|
+
protocolVersion: durableJobs.protocolVersion,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
})
|
|
140
200
|
})
|
|
141
201
|
|
|
142
202
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|