@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
|
@@ -10,7 +10,7 @@ import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
|
|
|
10
10
|
import { appendToArchive, type SessionToArchive } from './archive.js'
|
|
11
11
|
import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
|
|
12
12
|
import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-log.js'
|
|
13
|
-
import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
13
|
+
import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
14
14
|
import { localDay } from './local-day.js'
|
|
15
15
|
import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
16
16
|
import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
@@ -22,12 +22,32 @@ export interface Exchange {
|
|
|
22
22
|
content: string
|
|
23
23
|
timestamp: number
|
|
24
24
|
globalMsgNum?: number // Client's global message number for this Q&A pair
|
|
25
|
+
/** Durable query provenance. Legacy exchanges omit both fields. */
|
|
26
|
+
clientJobId?: string
|
|
27
|
+
generation?: number
|
|
25
28
|
/** Public attachment refs may live on either half of a Q&A pair: request
|
|
26
29
|
* photos on the user exchange, model-published images on the assistant
|
|
27
30
|
* exchange. Bytes, filesystem paths, and capabilities never persist here. */
|
|
28
31
|
attachments?: MediaAttachmentRef[]
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export interface ExchangeJobProvenance {
|
|
35
|
+
clientJobId?: string
|
|
36
|
+
generation?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A durable job generation is the exact reconciliation/removal identity.
|
|
40
|
+
* Requiring both fields prevents a stale generation from mutating its retry. */
|
|
41
|
+
export interface ExchangeJobIdentity {
|
|
42
|
+
clientJobId: string
|
|
43
|
+
generation: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReconciledExchange {
|
|
47
|
+
exchange: Exchange
|
|
48
|
+
created: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
interface Session {
|
|
32
52
|
id: string
|
|
33
53
|
exchanges: Exchange[]
|
|
@@ -58,6 +78,46 @@ interface SessionsFile {
|
|
|
58
78
|
savedAt: string
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
const MAX_CLIENT_JOB_ID_LENGTH = 128
|
|
82
|
+
const SAFE_CLIENT_JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
|
|
83
|
+
|
|
84
|
+
function validClientJobId(value: unknown): value is string {
|
|
85
|
+
return typeof value === 'string'
|
|
86
|
+
&& value.length > 0
|
|
87
|
+
&& value.length <= MAX_CLIENT_JOB_ID_LENGTH
|
|
88
|
+
&& SAFE_CLIENT_JOB_ID_RE.test(value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validGeneration(value: unknown): value is number {
|
|
92
|
+
return typeof value === 'number'
|
|
93
|
+
&& Number.isSafeInteger(value)
|
|
94
|
+
&& value > 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizedExchangeProvenance(
|
|
98
|
+
provenance: ExchangeJobProvenance | undefined,
|
|
99
|
+
): ExchangeJobProvenance {
|
|
100
|
+
if (!provenance || !validClientJobId(provenance.clientJobId)) return {}
|
|
101
|
+
return {
|
|
102
|
+
clientJobId: provenance.clientJobId,
|
|
103
|
+
...(validGeneration(provenance.generation) ? { generation: provenance.generation } : {}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateLoadedExchangeProvenance(exchange: Exchange): void {
|
|
108
|
+
if (!validClientJobId(exchange.clientJobId)) {
|
|
109
|
+
delete exchange.clientJobId
|
|
110
|
+
delete exchange.generation
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (!validGeneration(exchange.generation)) delete exchange.generation
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function matchesJobIdentity(exchange: Exchange, identity: ExchangeJobIdentity): boolean {
|
|
117
|
+
return exchange.clientJobId === identity.clientJobId
|
|
118
|
+
&& exchange.generation === identity.generation
|
|
119
|
+
}
|
|
120
|
+
|
|
61
121
|
function loadFromDisk(): void {
|
|
62
122
|
const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
|
|
63
123
|
if (result.status === 'missing') return // fresh start
|
|
@@ -81,6 +141,7 @@ function loadFromDisk(): void {
|
|
|
81
141
|
// Persistence boundary: malformed refs are dropped without sacrificing
|
|
82
142
|
// the surrounding exchange or the rest of the recovered session.
|
|
83
143
|
for (const exchange of session.exchanges) {
|
|
144
|
+
validateLoadedExchangeProvenance(exchange)
|
|
84
145
|
if ('attachments' in exchange && exchange.attachments !== undefined) {
|
|
85
146
|
const refs = parseMediaAttachmentRefs(exchange.attachments)
|
|
86
147
|
if (refs.length > 0) exchange.attachments = refs
|
|
@@ -121,6 +182,29 @@ function saveToDisk(): void {
|
|
|
121
182
|
}
|
|
122
183
|
}
|
|
123
184
|
|
|
185
|
+
/** Force the current in-memory conversation projection across an fsync-backed
|
|
186
|
+
* durability boundary. Durable query jobs call this only at terminal/recovery
|
|
187
|
+
* boundaries; the existing 500ms coalesced writer remains the normal hot path.
|
|
188
|
+
* Unlike saveToDisk(), failures are surfaced to the caller so it can suppress
|
|
189
|
+
* a compatibility notification and leave boot reconciliation to repair it. */
|
|
190
|
+
export function flushConversationToDisk(): void {
|
|
191
|
+
if (saveTimer) {
|
|
192
|
+
clearTimeout(saveTimer)
|
|
193
|
+
saveTimer = null
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(dirname(SESSION_FILE), { recursive: true })
|
|
197
|
+
const data: SessionsFile = {
|
|
198
|
+
sessions: Object.fromEntries(sessions),
|
|
199
|
+
savedAt: new Date().toISOString(),
|
|
200
|
+
}
|
|
201
|
+
durableAtomicWriteFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 })
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error('[conversation] Failed durable terminal flush:', err)
|
|
204
|
+
throw err
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
124
208
|
// Restore on module load
|
|
125
209
|
loadFromDisk()
|
|
126
210
|
|
|
@@ -316,6 +400,7 @@ export function addExchange(
|
|
|
316
400
|
content: string,
|
|
317
401
|
globalMsgNum?: number,
|
|
318
402
|
attachments?: MediaAttachmentRef[],
|
|
403
|
+
provenance?: ExchangeJobProvenance,
|
|
319
404
|
): Exchange {
|
|
320
405
|
let session = sessions.get(sessionId)
|
|
321
406
|
if (!session) {
|
|
@@ -328,6 +413,7 @@ export function addExchange(
|
|
|
328
413
|
content,
|
|
329
414
|
timestamp: Date.now(),
|
|
330
415
|
globalMsgNum,
|
|
416
|
+
...normalizedExchangeProvenance(provenance),
|
|
331
417
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
332
418
|
}
|
|
333
419
|
session.exchanges.push(exchange)
|
|
@@ -377,6 +463,113 @@ export function removeExchange(sessionId: string, exchange: Exchange): boolean {
|
|
|
377
463
|
return true
|
|
378
464
|
}
|
|
379
465
|
|
|
466
|
+
/** Return the exchanges owned by one exact durable job generation. The
|
|
467
|
+
* returned objects are the live exchange objects, matching getHistory's
|
|
468
|
+
* existing shallow-copy behavior. */
|
|
469
|
+
export function findExchangesByJobIdentity(
|
|
470
|
+
sessionId: string,
|
|
471
|
+
identity: ExchangeJobIdentity,
|
|
472
|
+
): Exchange[] {
|
|
473
|
+
const session = sessions.get(sessionId)
|
|
474
|
+
if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return []
|
|
475
|
+
return session.exchanges.filter(exchange => matchesJobIdentity(exchange, identity))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Remove only exchanges owned by one exact job generation. A role filter is
|
|
479
|
+
* used by failed runs to roll back their pending user turn without touching a
|
|
480
|
+
* completed assistant exchange or a newer retry generation. */
|
|
481
|
+
export function removeExchangesByJobIdentity(
|
|
482
|
+
sessionId: string,
|
|
483
|
+
identity: ExchangeJobIdentity,
|
|
484
|
+
role?: Exchange['role'],
|
|
485
|
+
): number {
|
|
486
|
+
const session = sessions.get(sessionId)
|
|
487
|
+
if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return 0
|
|
488
|
+
|
|
489
|
+
const before = session.exchanges.length
|
|
490
|
+
session.exchanges = session.exchanges.filter(exchange => (
|
|
491
|
+
!matchesJobIdentity(exchange, identity) || (role !== undefined && exchange.role !== role)
|
|
492
|
+
))
|
|
493
|
+
const removed = before - session.exchanges.length
|
|
494
|
+
if (removed === 0) return 0
|
|
495
|
+
|
|
496
|
+
session.lastActivity = Date.now()
|
|
497
|
+
scheduleSave()
|
|
498
|
+
scheduleCacheUpdate()
|
|
499
|
+
return removed
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Idempotently create or update one side of a durable job exchange pair.
|
|
503
|
+
* Duplicate same-role rows for the exact identity are collapsed in the same
|
|
504
|
+
* synchronous mutation before the existing coalesced save/cache path runs.
|
|
505
|
+
* Other jobs, generations, and the opposite role are never changed. */
|
|
506
|
+
export function reconcileExchangeByJobIdentity(
|
|
507
|
+
sessionId: string,
|
|
508
|
+
identity: ExchangeJobIdentity,
|
|
509
|
+
role: Exchange['role'],
|
|
510
|
+
content: string,
|
|
511
|
+
globalMsgNum?: number,
|
|
512
|
+
attachments?: MediaAttachmentRef[],
|
|
513
|
+
): ReconciledExchange {
|
|
514
|
+
if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
|
|
515
|
+
throw new Error('conversation: invalid durable job identity')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let session = sessions.get(sessionId)
|
|
519
|
+
if (!session) {
|
|
520
|
+
const now = Date.now()
|
|
521
|
+
session = {
|
|
522
|
+
id: sessionId,
|
|
523
|
+
exchanges: [],
|
|
524
|
+
lastActivity: now,
|
|
525
|
+
createdAt: now,
|
|
526
|
+
modelPreference: null,
|
|
527
|
+
contextBreaks: [],
|
|
528
|
+
}
|
|
529
|
+
sessions.set(sessionId, session)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const matchingIndexes: number[] = []
|
|
533
|
+
for (let i = 0; i < session.exchanges.length; i++) {
|
|
534
|
+
const exchange = session.exchanges[i]
|
|
535
|
+
if (exchange.role === role && matchesJobIdentity(exchange, identity)) matchingIndexes.push(i)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const validatedAttachments = parseMediaAttachmentRefs(attachments)
|
|
539
|
+
let exchange: Exchange
|
|
540
|
+
const created = matchingIndexes.length === 0
|
|
541
|
+
if (created) {
|
|
542
|
+
exchange = {
|
|
543
|
+
role,
|
|
544
|
+
content,
|
|
545
|
+
timestamp: Date.now(),
|
|
546
|
+
globalMsgNum,
|
|
547
|
+
clientJobId: identity.clientJobId,
|
|
548
|
+
generation: identity.generation,
|
|
549
|
+
...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
|
|
550
|
+
}
|
|
551
|
+
session.exchanges.push(exchange)
|
|
552
|
+
} else {
|
|
553
|
+
exchange = session.exchanges[matchingIndexes[0]]
|
|
554
|
+
exchange.content = content
|
|
555
|
+
exchange.globalMsgNum = globalMsgNum
|
|
556
|
+
if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
|
|
557
|
+
else delete exchange.attachments
|
|
558
|
+
|
|
559
|
+
// Remove from the end so earlier indexes stay stable; retain the oldest
|
|
560
|
+
// row's timestamp and position to preserve conversation ordering.
|
|
561
|
+
for (let i = matchingIndexes.length - 1; i >= 1; i--) {
|
|
562
|
+
session.exchanges.splice(matchingIndexes[i], 1)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
while (session.exchanges.length > MAX_EXCHANGES) session.exchanges.shift()
|
|
567
|
+
session.lastActivity = Date.now()
|
|
568
|
+
scheduleSave()
|
|
569
|
+
scheduleCacheUpdate()
|
|
570
|
+
return { exchange, created }
|
|
571
|
+
}
|
|
572
|
+
|
|
380
573
|
/** Clear a session — archive + log BEFORE deleting from the live Map.
|
|
381
574
|
* Async so callers can `await` archive completion before considering the
|
|
382
575
|
* session durable. If archive fails the session stays in the Map (retryable
|
|
@@ -16,7 +16,7 @@ import type { ModelImageInput } from './model-image-input.js'
|
|
|
16
16
|
// mutating the same conversation/CLI session concurrently.
|
|
17
17
|
const sessionRunTails = new Map<string, Promise<void>>()
|
|
18
18
|
|
|
19
|
-
async function
|
|
19
|
+
export async function acquireModelSessionRunLock(sessionId: string): Promise<() => void> {
|
|
20
20
|
const previous = sessionRunTails.get(sessionId) ?? Promise.resolve()
|
|
21
21
|
let openGate!: () => void
|
|
22
22
|
const gate = new Promise<void>(resolve => { openGate = resolve })
|
|
@@ -47,7 +47,7 @@ export async function callModelStreaming(
|
|
|
47
47
|
options?: CallOptions,
|
|
48
48
|
): Promise<string> {
|
|
49
49
|
const sid = getOrCreateSession(sessionId)
|
|
50
|
-
const release = await
|
|
50
|
+
const release = options?.sessionLockHeld ? (() => {}) : await acquireModelSessionRunLock(sid)
|
|
51
51
|
if (options?.abortSignal?.aborted) {
|
|
52
52
|
release()
|
|
53
53
|
throw new Error('model-router: request aborted before the model run started.')
|
|
@@ -70,16 +70,16 @@ export async function callModelStreaming(
|
|
|
70
70
|
}
|
|
71
71
|
const lockedCallbacks: StreamCallbacks = {
|
|
72
72
|
...callbacks,
|
|
73
|
-
onDone: (fullText, completedModel, cliSessionId, metadata) => {
|
|
73
|
+
onDone: async (fullText, completedModel, cliSessionId, metadata) => {
|
|
74
74
|
try {
|
|
75
|
-
callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
|
|
75
|
+
return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
|
|
76
76
|
} finally {
|
|
77
77
|
releaseTerminal()
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
|
-
onError: (error) => {
|
|
80
|
+
onError: async (error) => {
|
|
81
81
|
try {
|
|
82
|
-
callbacks.onError(error)
|
|
82
|
+
await callbacks.onError(error)
|
|
83
83
|
} finally {
|
|
84
84
|
releaseTerminal()
|
|
85
85
|
}
|