@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.
@@ -0,0 +1,1102 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { EventEmitter } from 'node:events'
3
+ import { chmod, mkdir, open, readFile, readdir, rm } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import {
6
+ mergeMediaAttachmentRefs,
7
+ parseMediaAttachmentRefs,
8
+ } from '../../shared/media-attachment.js'
9
+ import {
10
+ QUERY_JOB_LIMITS,
11
+ QUERY_JOB_SCHEMA_VERSION,
12
+ boundedText,
13
+ isTerminalQueryJobStatus,
14
+ normalizeQueryJobError,
15
+ parseQueryJobOutputImageStats,
16
+ parseQueryJobRequest,
17
+ requestFingerprint,
18
+ sanitizeQueryJobActivity,
19
+ type QueryJobActivity,
20
+ type QueryJobActivityKind,
21
+ type QueryJobError,
22
+ type QueryJobEvent,
23
+ type QueryJobEventType,
24
+ type QueryJobProviderLinkage,
25
+ type QueryJobReplay,
26
+ type QueryJobRequest,
27
+ type QueryJobSnapshot,
28
+ type QueryJobStatus,
29
+ type QueryJobStoreHealth,
30
+ } from './query-job-types.js'
31
+
32
+ const PARTITION_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/
33
+ const MAX_JOURNAL_RECORD_BYTES = 512 * 1024
34
+ const MAX_CHUNK_DELTA_CHARS = 16_000
35
+ export const QUERY_JOB_ORPHAN_FENCE_MS = 21 * 60_000
36
+
37
+ interface QueryJobJournalRecord {
38
+ schemaVersion: typeof QUERY_JOB_SCHEMA_VERSION
39
+ recordId: string
40
+ partitionDay: string
41
+ persistedAt: string
42
+ bootId: string
43
+ jobId: string
44
+ clientJobId: string
45
+ generation: number
46
+ turnId: string
47
+ requestFingerprint: string
48
+ eventSeq: number
49
+ type: QueryJobEventType
50
+ status: QueryJobStatus
51
+ request?: QueryJobRequest
52
+ patch: Record<string, unknown>
53
+ eventData: Record<string, unknown>
54
+ }
55
+
56
+ interface HydratedQueryJob {
57
+ request: QueryJobRequest
58
+ snapshot: QueryJobSnapshot
59
+ events: QueryJobEvent[]
60
+ lastBootId: string
61
+ }
62
+
63
+ interface QueryJobIdentity {
64
+ jobId: string
65
+ clientJobId: string
66
+ generation: number
67
+ sessionId: string
68
+ fingerprint: string
69
+ status: QueryJobStatus
70
+ updatedAt: string
71
+ orphanFenceUntil?: string
72
+ }
73
+
74
+ export interface QueryJobMutationResult {
75
+ applied: boolean
76
+ job: QueryJobSnapshot
77
+ event?: QueryJobEvent
78
+ }
79
+
80
+ export interface QueryJobAdmissionResult {
81
+ created: boolean
82
+ job: QueryJobSnapshot
83
+ }
84
+
85
+ export interface QueryJobExecutionRecord {
86
+ request: QueryJobRequest
87
+ job: QueryJobSnapshot
88
+ }
89
+
90
+ export interface QueryJobSubscription {
91
+ replay: QueryJobReplay
92
+ unsubscribe: () => void
93
+ }
94
+
95
+ export interface QueryJobJournalStorage {
96
+ prepare(root: string): Promise<void>
97
+ listPartitions(root: string): Promise<string[]>
98
+ readPartition(root: string, partition: string): Promise<string>
99
+ removePartition(root: string, partition: string): Promise<void>
100
+ append(root: string, partitionDay: string, line: string): Promise<void>
101
+ }
102
+
103
+ /** Default journal implementation. The store serializes calls; this adapter
104
+ * supplies the OS durability boundary (append + fsync) and private modes. */
105
+ export class NodeQueryJobJournalStorage implements QueryJobJournalStorage {
106
+ async prepare(root: string): Promise<void> {
107
+ await mkdir(root, { recursive: true, mode: 0o700 })
108
+ await chmod(root, 0o700)
109
+ }
110
+
111
+ async listPartitions(root: string): Promise<string[]> {
112
+ try {
113
+ return (await readdir(root)).filter(name => PARTITION_RE.test(name)).sort()
114
+ } catch (error) {
115
+ if ((error as { code?: string }).code === 'ENOENT') return []
116
+ throw error
117
+ }
118
+ }
119
+
120
+ readPartition(root: string, partition: string): Promise<string> {
121
+ return readFile(join(root, partition), 'utf8')
122
+ }
123
+
124
+ async removePartition(root: string, partition: string): Promise<void> {
125
+ await rm(join(root, partition), { force: true })
126
+ }
127
+
128
+ async append(root: string, partitionDay: string, line: string): Promise<void> {
129
+ await this.prepare(root)
130
+ const path = join(root, `${partitionDay}.jsonl`)
131
+ const handle = await open(path, 'a+', 0o600)
132
+ try {
133
+ await handle.chmod(0o600)
134
+ const existing = await handle.stat()
135
+ if (existing.size > 0) {
136
+ const tail = Buffer.allocUnsafe(1)
137
+ const { bytesRead } = await handle.read(tail, 0, 1, existing.size - 1)
138
+ // Preserve a torn row as malformed evidence, then put the next valid
139
+ // record on a fresh line so hydration can recover it independently.
140
+ if (bytesRead === 1 && tail[0] !== 0x0a) await handle.write('\n')
141
+ }
142
+ await handle.write(`${line}\n`)
143
+ await handle.sync()
144
+ } finally {
145
+ await handle.close()
146
+ }
147
+ }
148
+ }
149
+
150
+ export interface QueryJobStoreOptions {
151
+ root: string
152
+ bootId: string
153
+ storage?: QueryJobJournalStorage
154
+ now?: () => Date
155
+ retentionDays?: number
156
+ maxHydratedJobs?: number
157
+ maxReplayEvents?: number
158
+ maxActivityEntries?: number
159
+ }
160
+
161
+ export class QueryJobStoreError extends Error {
162
+ constructor(readonly code: string, message = code) {
163
+ super(message)
164
+ this.name = 'QueryJobStoreError'
165
+ }
166
+ }
167
+
168
+ export class QueryJobPersistenceError extends QueryJobStoreError {
169
+ constructor(code = 'query_job_persistence_failed', message = code) {
170
+ super(code, message)
171
+ this.name = 'QueryJobPersistenceError'
172
+ }
173
+ }
174
+
175
+ export class QueryJobNotFoundError extends QueryJobStoreError {
176
+ constructor(readonly jobId: string) {
177
+ super('query_job_not_found')
178
+ this.name = 'QueryJobNotFoundError'
179
+ }
180
+ }
181
+
182
+ export class QueryJobGenerationMismatchError extends QueryJobStoreError {
183
+ constructor(readonly jobId: string, readonly expected: number, readonly received: number) {
184
+ super('query_job_generation_mismatch')
185
+ this.name = 'QueryJobGenerationMismatchError'
186
+ }
187
+ }
188
+
189
+ export class QueryJobIdentityConflictError extends QueryJobStoreError {
190
+ constructor(readonly clientJobId: string, readonly generation: number) {
191
+ super('job_identity_conflict')
192
+ this.name = 'QueryJobIdentityConflictError'
193
+ }
194
+ }
195
+
196
+ export class QueryJobActiveGenerationError extends QueryJobStoreError {
197
+ constructor(readonly clientJobId: string) {
198
+ super('job_generation_active')
199
+ this.name = 'QueryJobActiveGenerationError'
200
+ }
201
+ }
202
+
203
+ export class QueryJobGenerationOrderError extends QueryJobStoreError {
204
+ constructor(readonly clientJobId: string) {
205
+ super('job_generation_out_of_order')
206
+ this.name = 'QueryJobGenerationOrderError'
207
+ }
208
+ }
209
+
210
+ export class QueryJobProviderOrphanFenceError extends QueryJobStoreError {
211
+ constructor(readonly retryAfterMs: number) {
212
+ super('provider_orphan_fence', 'A prior provider may still be exiting. Retry after the orphan fence clears.')
213
+ this.name = 'QueryJobProviderOrphanFenceError'
214
+ }
215
+ }
216
+
217
+ export class QueryJobAnswerCommittingError extends QueryJobStoreError {
218
+ constructor() {
219
+ super('query_job_answer_committing', 'The answer is already committing and can no longer be canceled.')
220
+ this.name = 'QueryJobAnswerCommittingError'
221
+ }
222
+ }
223
+
224
+ export class QueryJobNotTerminalError extends QueryJobStoreError {
225
+ constructor() {
226
+ super('query_job_not_terminal')
227
+ this.name = 'QueryJobNotTerminalError'
228
+ }
229
+ }
230
+
231
+ function clone<T>(value: T): T {
232
+ return structuredClone(value)
233
+ }
234
+
235
+ function identityKey(clientJobId: string, generation: number): string {
236
+ return `${clientJobId}:${generation}`
237
+ }
238
+
239
+ function localPartitionDay(date: Date): string {
240
+ return new Intl.DateTimeFormat('en-CA', {
241
+ timeZone: 'America/Chicago',
242
+ year: 'numeric', month: '2-digit', day: '2-digit',
243
+ }).format(date)
244
+ }
245
+
246
+ function retainedCutoffDay(now: Date, retainedDays: number): string {
247
+ const localDay = localPartitionDay(now)
248
+ const noon = new Date(`${localDay}T12:00:00Z`)
249
+ // A partition begins at local midnight, while each job's retentionUntil is
250
+ // acceptedAt + N full days. Keep the boundary partition for one additional
251
+ // local day so a 23:59 admission is never deleted at 00:00 before its TTL.
252
+ noon.setUTCDate(noon.getUTCDate() - Math.max(0, retainedDays))
253
+ return noon.toISOString().slice(0, 10)
254
+ }
255
+
256
+ function errorCode(error: unknown): string {
257
+ const code = (error as { code?: unknown })?.code
258
+ return typeof code === 'string' && code.slice(0, 80) || 'query_job_persistence_failed'
259
+ }
260
+
261
+ function safeOptional(value: unknown, max = 256): string | undefined {
262
+ if (typeof value !== 'string' || !value.trim()) return undefined
263
+ return sanitizeQueryJobActivity(value).text.slice(0, max)
264
+ }
265
+
266
+ const ALLOWED_TRANSITIONS: Record<QueryJobStatus, ReadonlySet<QueryJobStatus>> = {
267
+ accepted: new Set(['starting', 'failed', 'canceled', 'interrupted']),
268
+ starting: new Set(['running', 'answer_ready', 'completed', 'failed', 'canceled', 'interrupted']),
269
+ running: new Set(['answer_ready', 'completed', 'failed', 'canceled', 'interrupted']),
270
+ // Provider generation has crossed its durable commit barrier. From here the
271
+ // answer may only become completed; cancellation, provider errors, process
272
+ // shutdown, and post-processing failures must never discard durable text.
273
+ answer_ready: new Set(['completed']),
274
+ completed: new Set(),
275
+ failed: new Set(),
276
+ canceled: new Set(),
277
+ interrupted: new Set(),
278
+ }
279
+
280
+ export class QueryJobStore {
281
+ private readonly storage: QueryJobJournalStorage
282
+ private readonly now: () => Date
283
+ private readonly retentionDays: number
284
+ private readonly maxHydratedJobs: number
285
+ private readonly maxReplayEvents: number
286
+ private readonly maxActivityEntries: number
287
+ private readonly jobs = new Map<string, HydratedQueryJob>()
288
+ private readonly identitiesByJobId = new Map<string, QueryJobIdentity>()
289
+ private readonly identitiesByKey = new Map<string, QueryJobIdentity>()
290
+ private readonly emitter = new EventEmitter()
291
+ private appendTail: Promise<void> = Promise.resolve()
292
+ private initPromise: Promise<QueryJobStoreHealth> | null = null
293
+ private partitions: string[] = []
294
+ private subscriberCount = 0
295
+ private readonly health: QueryJobStoreHealth
296
+
297
+ constructor(private readonly options: QueryJobStoreOptions) {
298
+ this.storage = options.storage ?? new NodeQueryJobJournalStorage()
299
+ this.now = options.now ?? (() => new Date())
300
+ this.retentionDays = Math.max(1, options.retentionDays ?? QUERY_JOB_LIMITS.retainedDays)
301
+ this.maxHydratedJobs = Math.max(1, options.maxHydratedJobs ?? QUERY_JOB_LIMITS.hydratedJobs)
302
+ this.maxReplayEvents = Math.max(1, options.maxReplayEvents ?? QUERY_JOB_LIMITS.replayEvents)
303
+ this.maxActivityEntries = Math.max(1, options.maxActivityEntries ?? QUERY_JOB_LIMITS.activityEntries)
304
+ this.emitter.setMaxListeners(1_000)
305
+ this.health = {
306
+ state: 'new',
307
+ bootId: options.bootId,
308
+ hydratedJobs: 0,
309
+ retainedIdentities: 0,
310
+ subscribers: 0,
311
+ malformedRows: 0,
312
+ journalFailures: 0,
313
+ interruptedOnBoot: 0,
314
+ evictedHydratedJobs: 0,
315
+ lastErrorCode: null,
316
+ lastSuccessfulWriteAt: null,
317
+ rootFingerprint: createHash('sha256').update(options.root).digest('hex').slice(0, 16),
318
+ counts: {
319
+ accepted: 0, starting: 0, running: 0, answer_ready: 0,
320
+ completed: 0, failed: 0, canceled: 0, interrupted: 0,
321
+ },
322
+ }
323
+ }
324
+
325
+ async init(): Promise<QueryJobStoreHealth> {
326
+ if (this.initPromise) return this.initPromise
327
+ this.initPromise = this.initialize()
328
+ return this.initPromise
329
+ }
330
+
331
+ private async initialize(): Promise<QueryJobStoreHealth> {
332
+ try {
333
+ await this.storage.prepare(this.options.root)
334
+ const cutoff = retainedCutoffDay(this.now(), this.retentionDays)
335
+ const existing = await this.storage.listPartitions(this.options.root)
336
+ for (const partition of existing) {
337
+ if (partition.slice(0, 10) < cutoff) await this.storage.removePartition(this.options.root, partition)
338
+ }
339
+ this.partitions = await this.storage.listPartitions(this.options.root)
340
+ for (const partition of this.partitions) await this.hydratePartition(partition)
341
+ this.health.state = 'ready'
342
+
343
+ // A local child process cannot survive a server boot. Persist the
344
+ // classification once; never invoke a runner during hydration. An
345
+ // answer_ready record is different: provider generation has already
346
+ // crossed the durable commit point, so finish it from the journaled
347
+ // answer instead of throwing away a reply merely because bridge
348
+ // post-processing was interrupted by the restart.
349
+ const priorBootJobs = [...this.jobs.values()].filter(job =>
350
+ !isTerminalQueryJobStatus(job.snapshot.status) && job.lastBootId !== this.options.bootId)
351
+ for (const job of priorBootJobs) {
352
+ const snapshot = job.snapshot
353
+ if (snapshot.status === 'answer_ready') {
354
+ await this.complete(snapshot.jobId, {
355
+ text: snapshot.partialText,
356
+ attachments: snapshot.attachments,
357
+ outputImageStats: snapshot.outputImageStats,
358
+ provider: snapshot.provider,
359
+ resolvedModel: snapshot.resolvedModel,
360
+ cliSessionId: snapshot.cliSessionId,
361
+ claudeRunId: snapshot.claudeRunId,
362
+ codexRunId: snapshot.codexRunId,
363
+ codexThreadId: snapshot.codexThreadId,
364
+ })
365
+ } else {
366
+ const result = await this.interrupt(snapshot.jobId, 'server_restarted')
367
+ if (result.applied) this.health.interruptedOnBoot++
368
+ }
369
+ }
370
+ this.trimHydratedJobs()
371
+ this.refreshHealth()
372
+ return this.getHealth()
373
+ } catch (error) {
374
+ this.markPersistenceFailure(error)
375
+ throw error instanceof QueryJobPersistenceError
376
+ ? error : new QueryJobPersistenceError(errorCode(error), error instanceof Error ? error.message : String(error))
377
+ }
378
+ }
379
+
380
+ private async hydratePartition(partition: string, onlyJobId?: string): Promise<void> {
381
+ let text: string
382
+ try {
383
+ text = await this.storage.readPartition(this.options.root, partition)
384
+ } catch (error) {
385
+ if ((error as { code?: string }).code === 'ENOENT') return
386
+ throw error
387
+ }
388
+ for (const line of text.split(/\r?\n/)) {
389
+ if (!line.trim()) continue
390
+ try {
391
+ const parsed = JSON.parse(line) as unknown
392
+ const record = this.parseJournalRecord(parsed)
393
+ if (!record || (onlyJobId && record.jobId !== onlyJobId)) {
394
+ if (!record && !onlyJobId) this.health.malformedRows++
395
+ continue
396
+ }
397
+ this.applyRecord(record, false)
398
+ } catch {
399
+ if (!onlyJobId) this.health.malformedRows++
400
+ }
401
+ }
402
+ }
403
+
404
+ private parseJournalRecord(raw: unknown): QueryJobJournalRecord | null {
405
+ if (!raw || typeof raw !== 'object') return null
406
+ const r = raw as Record<string, unknown>
407
+ if (r.schemaVersion !== QUERY_JOB_SCHEMA_VERSION
408
+ || typeof r.recordId !== 'string'
409
+ || typeof r.partitionDay !== 'string'
410
+ || typeof r.persistedAt !== 'string'
411
+ || typeof r.bootId !== 'string'
412
+ || typeof r.jobId !== 'string'
413
+ || typeof r.clientJobId !== 'string'
414
+ || !Number.isSafeInteger(r.generation)
415
+ || typeof r.turnId !== 'string'
416
+ || typeof r.requestFingerprint !== 'string'
417
+ || !Number.isSafeInteger(r.eventSeq)
418
+ || typeof r.type !== 'string'
419
+ || typeof r.status !== 'string'
420
+ || !r.patch || typeof r.patch !== 'object'
421
+ || !r.eventData || typeof r.eventData !== 'object') return null
422
+ const statuses: QueryJobStatus[] = ['accepted', 'starting', 'running', 'answer_ready', 'completed', 'failed', 'canceled', 'interrupted']
423
+ const eventTypes: QueryJobEventType[] = [...statuses, 'chunk', 'tool_status', 'activity_line', 'acknowledged']
424
+ if (!statuses.includes(r.status as QueryJobStatus) || !eventTypes.includes(r.type as QueryJobEventType)) return null
425
+ return r as unknown as QueryJobJournalRecord
426
+ }
427
+
428
+ private applyRecord(record: QueryJobJournalRecord, publish: boolean): QueryJobEvent | undefined {
429
+ let hydrated = this.jobs.get(record.jobId)
430
+ if (!hydrated) {
431
+ if (record.type !== 'accepted' || !record.request || record.eventSeq !== 1) return undefined
432
+ let request: QueryJobRequest
433
+ try { request = parseQueryJobRequest(record.request) } catch { return undefined }
434
+ const fingerprint = requestFingerprint(request)
435
+ if (fingerprint !== record.requestFingerprint
436
+ || request.clientJobId !== record.clientJobId
437
+ || request.generation !== record.generation) return undefined
438
+ const acceptedAt = record.persistedAt
439
+ const retentionUntil = new Date(new Date(acceptedAt).getTime() + this.retentionDays * 86_400_000).toISOString()
440
+ hydrated = {
441
+ request,
442
+ events: [],
443
+ lastBootId: record.bootId,
444
+ snapshot: {
445
+ schemaVersion: QUERY_JOB_SCHEMA_VERSION,
446
+ jobId: record.jobId,
447
+ clientJobId: record.clientJobId,
448
+ generation: record.generation,
449
+ turnId: record.turnId,
450
+ requestFingerprint: fingerprint,
451
+ status: 'accepted',
452
+ eventSeq: 0,
453
+ oldestEventSeq: 1,
454
+ sessionId: request.sessionId,
455
+ ...(request.model ? { requestedModel: request.model } : {}),
456
+ ...(request.effort ? { effort: request.effort } : {}),
457
+ ...(request.messageEra ? { messageEra: request.messageEra } : {}),
458
+ ...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
459
+ ...(request.handoffCode ? { handoffCode: request.handoffCode } : {}),
460
+ attachments: clone(request.attachmentRefs),
461
+ partialText: '',
462
+ partialTruncated: false,
463
+ activity: [],
464
+ acceptedAt,
465
+ updatedAt: acceptedAt,
466
+ retentionUntil,
467
+ },
468
+ }
469
+ this.jobs.set(record.jobId, hydrated)
470
+ }
471
+ if (record.eventSeq <= hydrated.snapshot.eventSeq) return undefined
472
+ if (record.type !== 'accepted'
473
+ && record.type !== 'acknowledged'
474
+ && isTerminalQueryJobStatus(hydrated.snapshot.status)) return undefined
475
+ if (record.type !== 'accepted' && record.status !== hydrated.snapshot.status
476
+ && !ALLOWED_TRANSITIONS[hydrated.snapshot.status].has(record.status)) return undefined
477
+
478
+ const snapshot = hydrated.snapshot
479
+ snapshot.status = record.status
480
+ snapshot.eventSeq = record.eventSeq
481
+ snapshot.updatedAt = record.persistedAt
482
+ hydrated.lastBootId = record.bootId
483
+
484
+ const patch = record.patch
485
+ if (record.type === 'running') {
486
+ snapshot.startedAt = typeof patch.startedAt === 'string' ? patch.startedAt : record.persistedAt
487
+ this.applyLinkage(snapshot, patch)
488
+ } else if (record.type === 'chunk') {
489
+ if (typeof patch.partialText === 'string') snapshot.partialText = patch.partialText
490
+ snapshot.partialTruncated = patch.partialTruncated === true
491
+ } else if (record.type === 'tool_status' || record.type === 'activity_line') {
492
+ this.applyActivity(snapshot, record)
493
+ } else if (record.type === 'answer_ready') {
494
+ snapshot.answerReadyAt = typeof patch.answerReadyAt === 'string' ? patch.answerReadyAt : record.persistedAt
495
+ if (typeof patch.partialText === 'string') snapshot.partialText = patch.partialText
496
+ snapshot.partialTruncated = patch.partialTruncated === true
497
+ this.applyLinkage(snapshot, patch)
498
+ } else if (record.type === 'completed') {
499
+ snapshot.completedAt = typeof patch.completedAt === 'string' ? patch.completedAt : record.persistedAt
500
+ if (typeof patch.response === 'string') snapshot.response = patch.response
501
+ snapshot.responseTruncated = patch.responseTruncated === true
502
+ if (typeof patch.partialText === 'string') snapshot.partialText = patch.partialText
503
+ snapshot.partialTruncated = patch.partialTruncated === true
504
+ snapshot.attachments = mergeMediaAttachmentRefs(snapshot.attachments, patch.attachments)
505
+ const outputImageStats = parseQueryJobOutputImageStats(patch.outputImageStats)
506
+ if (outputImageStats) snapshot.outputImageStats = outputImageStats
507
+ else delete snapshot.outputImageStats
508
+ this.applyLinkage(snapshot, patch)
509
+ delete snapshot.error
510
+ } else if (record.type === 'failed' || record.type === 'canceled' || record.type === 'interrupted') {
511
+ snapshot.completedAt = typeof patch.completedAt === 'string' ? patch.completedAt : record.persistedAt
512
+ snapshot.error = normalizeQueryJobError(patch.error, record.type)
513
+ if (typeof patch.orphanFenceUntil === 'string') snapshot.orphanFenceUntil = patch.orphanFenceUntil
514
+ } else if (record.type === 'acknowledged') {
515
+ snapshot.acknowledgedAt = typeof patch.acknowledgedAt === 'string'
516
+ ? patch.acknowledgedAt : record.persistedAt
517
+ }
518
+
519
+ const event: QueryJobEvent = {
520
+ type: record.type,
521
+ eventSeq: record.eventSeq,
522
+ jobId: record.jobId,
523
+ clientJobId: record.clientJobId,
524
+ generation: record.generation,
525
+ status: record.status,
526
+ at: record.persistedAt,
527
+ data: clone(record.eventData),
528
+ }
529
+ hydrated.events.push(event)
530
+ if (hydrated.events.length > this.maxReplayEvents) hydrated.events.shift()
531
+ snapshot.oldestEventSeq = hydrated.events[0]?.eventSeq ?? snapshot.eventSeq + 1
532
+
533
+ const identity: QueryJobIdentity = {
534
+ jobId: record.jobId,
535
+ clientJobId: record.clientJobId,
536
+ generation: record.generation,
537
+ sessionId: hydrated.request.sessionId,
538
+ fingerprint: record.requestFingerprint,
539
+ status: record.status,
540
+ updatedAt: record.persistedAt,
541
+ ...(snapshot.orphanFenceUntil ? { orphanFenceUntil: snapshot.orphanFenceUntil } : {}),
542
+ }
543
+ this.identitiesByJobId.set(identity.jobId, identity)
544
+ this.identitiesByKey.set(identityKey(identity.clientJobId, identity.generation), identity)
545
+ this.refreshHealth()
546
+ if (publish) this.emitter.emit(record.jobId, clone(event))
547
+ return event
548
+ }
549
+
550
+ private applyLinkage(snapshot: QueryJobSnapshot, raw: Record<string, unknown>): void {
551
+ const provider = raw.provider === 'claude' || raw.provider === 'codex' ? raw.provider : undefined
552
+ if (provider) snapshot.provider = provider
553
+ const fields = ['resolvedModel', 'cliSessionId', 'claudeRunId', 'codexRunId', 'codexThreadId'] as const
554
+ for (const field of fields) {
555
+ const value = safeOptional(raw[field])
556
+ if (value) snapshot[field] = value
557
+ }
558
+ const providerOwnershipConfirmedAt = safeOptional(raw.providerOwnershipConfirmedAt)
559
+ if (providerOwnershipConfirmedAt) snapshot.providerOwnershipConfirmedAt = providerOwnershipConfirmedAt
560
+ }
561
+
562
+ private applyActivity(snapshot: QueryJobSnapshot, record: QueryJobJournalRecord): void {
563
+ const kind: QueryJobActivityKind = record.eventData.kind === 'input' || record.eventData.kind === 'output'
564
+ ? record.eventData.kind : record.eventData.kind === 'gap' ? 'gap' : 'status'
565
+ const safe = sanitizeQueryJobActivity(record.eventData.text)
566
+ const previous = snapshot.activity.at(-1)
567
+ if (previous && previous.kind === kind && previous.text === safe.text) {
568
+ previous.repeatCount = (previous.repeatCount ?? 1) + 1
569
+ previous.eventSeq = record.eventSeq
570
+ previous.at = record.persistedAt
571
+ return
572
+ }
573
+ snapshot.activity.push({ eventSeq: record.eventSeq, at: record.persistedAt, kind, text: safe.text })
574
+ if (snapshot.activity.length > this.maxActivityEntries) snapshot.activity.shift()
575
+ }
576
+
577
+ private async appendRecord(record: QueryJobJournalRecord): Promise<void> {
578
+ const serialized = JSON.stringify(record)
579
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_JOURNAL_RECORD_BYTES) {
580
+ throw new QueryJobPersistenceError('query_job_record_too_large')
581
+ }
582
+ try {
583
+ await this.storage.append(this.options.root, record.partitionDay, serialized)
584
+ const partition = `${record.partitionDay}.jsonl`
585
+ if (!this.partitions.includes(partition)) this.partitions.push(partition)
586
+ this.partitions.sort()
587
+ this.health.lastSuccessfulWriteAt = this.now().toISOString()
588
+ } catch (error) {
589
+ this.markPersistenceFailure(error)
590
+ throw new QueryJobPersistenceError(errorCode(error), error instanceof Error ? error.message : String(error))
591
+ }
592
+ }
593
+
594
+ private markPersistenceFailure(error: unknown): void {
595
+ this.health.state = 'degraded'
596
+ this.health.journalFailures++
597
+ this.health.lastErrorCode = errorCode(error)
598
+ }
599
+
600
+ private assertWritable(): void {
601
+ if (this.health.state === 'degraded') throw new QueryJobPersistenceError('query_job_store_degraded')
602
+ if (this.health.state !== 'ready') throw new QueryJobPersistenceError('query_job_store_not_ready')
603
+ }
604
+
605
+ private async ensureInitialized(): Promise<void> {
606
+ if (this.health.state === 'ready' || this.health.state === 'degraded') return
607
+ await this.init()
608
+ }
609
+
610
+ private enqueue<T>(operation: () => Promise<T>): Promise<T> {
611
+ const result = this.appendTail.catch(() => {}).then(operation)
612
+ this.appendTail = result.then(() => {}, () => {})
613
+ return result
614
+ }
615
+
616
+ async admit(raw: unknown): Promise<QueryJobAdmissionResult> {
617
+ await this.ensureInitialized()
618
+ const request = parseQueryJobRequest(raw)
619
+ const fingerprint = requestFingerprint(request)
620
+ const existingIdentity = this.identitiesByKey.get(identityKey(request.clientJobId, request.generation))
621
+ if (existingIdentity) await this.ensureHydrated(existingIdentity.jobId)
622
+
623
+ return this.enqueue(async () => {
624
+ this.assertWritable()
625
+ const key = identityKey(request.clientJobId, request.generation)
626
+ const duplicate = this.identitiesByKey.get(key)
627
+ if (duplicate) {
628
+ if (duplicate.fingerprint !== fingerprint) {
629
+ throw new QueryJobIdentityConflictError(request.clientJobId, request.generation)
630
+ }
631
+ const hydrated = this.jobs.get(duplicate.jobId)
632
+ if (!hydrated) throw new QueryJobNotFoundError(duplicate.jobId)
633
+ return { created: false, job: clone(hydrated.snapshot) }
634
+ }
635
+
636
+ const identities = [...this.identitiesByJobId.values()]
637
+ const lineage = identities.filter(item => item.clientJobId === request.clientJobId)
638
+ if (lineage.some(item => !isTerminalQueryJobStatus(item.status))) {
639
+ throw new QueryJobActiveGenerationError(request.clientJobId)
640
+ }
641
+ const nowMs = this.now().getTime()
642
+ // Provider sessions, not phone queue ids, are the concurrency boundary.
643
+ // A restarted client can allocate a fresh clientJobId; it must not bypass
644
+ // an orphan fence and resume the same provider session too early.
645
+ const sessionLineage = identities.filter(item => item.sessionId === request.sessionId)
646
+ const fencedUntil = sessionLineage.reduce((latest, item) => {
647
+ if (item.status !== 'interrupted' || !item.orphanFenceUntil) return latest
648
+ const value = new Date(item.orphanFenceUntil).getTime()
649
+ return Number.isFinite(value) ? Math.max(latest, value) : latest
650
+ }, 0)
651
+ if (fencedUntil > nowMs) throw new QueryJobProviderOrphanFenceError(fencedUntil - nowMs)
652
+ const highestGeneration = lineage.reduce((max, item) => Math.max(max, item.generation), 0)
653
+ if (request.generation <= highestGeneration) throw new QueryJobGenerationOrderError(request.clientJobId)
654
+
655
+ const now = this.now().toISOString()
656
+ const jobId = randomUUID()
657
+ const turnId = randomUUID()
658
+ const record: QueryJobJournalRecord = {
659
+ schemaVersion: QUERY_JOB_SCHEMA_VERSION,
660
+ recordId: randomUUID(),
661
+ partitionDay: localPartitionDay(this.now()),
662
+ persistedAt: now,
663
+ bootId: this.options.bootId,
664
+ jobId,
665
+ clientJobId: request.clientJobId,
666
+ generation: request.generation,
667
+ turnId,
668
+ requestFingerprint: fingerprint,
669
+ eventSeq: 1,
670
+ type: 'accepted',
671
+ status: 'accepted',
672
+ request,
673
+ patch: {},
674
+ eventData: { requestFingerprint: fingerprint },
675
+ }
676
+ await this.appendRecord(record)
677
+ this.applyRecord(record, true)
678
+ this.trimHydratedJobs(jobId)
679
+ return { created: true, job: clone(this.jobs.get(jobId)!.snapshot) }
680
+ })
681
+ }
682
+
683
+ async markStarting(jobId: string): Promise<QueryJobMutationResult> {
684
+ return this.transition(jobId, 'starting', 'starting', {}, {})
685
+ }
686
+
687
+ async markRunning(jobId: string, linkage: QueryJobProviderLinkage): Promise<QueryJobMutationResult> {
688
+ const safe = this.safeLinkage(linkage)
689
+ const startedAt = this.now().toISOString()
690
+ return this.transition(jobId, 'running', 'running', { ...safe, startedAt }, { ...safe })
691
+ }
692
+
693
+ /** Persist stable provider ownership while the job remains running. The
694
+ * bridge awaits this boundary before sending user input to the child. */
695
+ async updateLinkage(jobId: string, linkage: QueryJobProviderLinkage): Promise<QueryJobMutationResult> {
696
+ const safe = this.safeLinkage(linkage)
697
+ const providerOwnershipConfirmedAt = this.now().toISOString()
698
+ return this.mutateSameStatus(jobId, 'running', {
699
+ ...safe,
700
+ providerOwnershipConfirmedAt,
701
+ }, {
702
+ ...safe,
703
+ providerOwnershipConfirmedAt,
704
+ })
705
+ }
706
+
707
+ async appendPartial(
708
+ jobId: string,
709
+ delta: string,
710
+ partialText: string,
711
+ alreadyTruncated = false,
712
+ ): Promise<QueryJobMutationResult> {
713
+ const partial = boundedText(partialText, QUERY_JOB_LIMITS.partialChars)
714
+ const boundedDelta = boundedText(delta, MAX_CHUNK_DELTA_CHARS)
715
+ const partialTruncated = partial.truncated || alreadyTruncated
716
+ return this.mutateSameStatus(jobId, 'chunk', {
717
+ partialText: partial.text,
718
+ partialTruncated,
719
+ }, {
720
+ text: boundedDelta.text,
721
+ partialText: partial.text,
722
+ partialTruncated,
723
+ deltaTruncated: boundedDelta.truncated,
724
+ })
725
+ }
726
+
727
+ async appendActivity(
728
+ jobId: string,
729
+ kind: Exclude<QueryJobActivityKind, 'gap'>,
730
+ text: string,
731
+ ): Promise<QueryJobMutationResult> {
732
+ const safe = sanitizeQueryJobActivity(text)
733
+ const type: QueryJobEventType = kind === 'status' ? 'tool_status' : 'activity_line'
734
+ return this.mutateSameStatus(jobId, type, {}, { kind, text: safe.text, truncated: safe.truncated })
735
+ }
736
+
737
+ async markAnswerReady(
738
+ jobId: string,
739
+ fullText: string,
740
+ linkage: QueryJobProviderLinkage = {},
741
+ ): Promise<QueryJobMutationResult> {
742
+ const partial = boundedText(fullText, QUERY_JOB_LIMITS.partialChars)
743
+ const safeLinkage = this.safeLinkage(linkage)
744
+ const answerReadyAt = this.now().toISOString()
745
+ return this.transition(jobId, 'answer_ready', 'answer_ready', {
746
+ ...safeLinkage,
747
+ answerReadyAt,
748
+ partialText: partial.text,
749
+ partialTruncated: partial.truncated,
750
+ }, {
751
+ ...safeLinkage,
752
+ partialText: partial.text,
753
+ partialTruncated: partial.truncated,
754
+ })
755
+ }
756
+
757
+ async complete(
758
+ jobId: string,
759
+ input: { text: string; attachments?: unknown; outputImageStats?: unknown } & QueryJobProviderLinkage,
760
+ ): Promise<QueryJobMutationResult> {
761
+ const response = boundedText(input.text, QUERY_JOB_LIMITS.terminalResponseChars)
762
+ const partial = boundedText(input.text, QUERY_JOB_LIMITS.partialChars)
763
+ const linkage = this.safeLinkage(input)
764
+ const attachments = parseMediaAttachmentRefs(input.attachments)
765
+ const outputImageStats = parseQueryJobOutputImageStats(input.outputImageStats)
766
+ const completedAt = this.now().toISOString()
767
+ return this.transition(jobId, 'completed', 'completed', {
768
+ ...linkage,
769
+ completedAt,
770
+ response: response.text,
771
+ responseTruncated: response.truncated,
772
+ partialText: partial.text,
773
+ partialTruncated: partial.truncated,
774
+ attachments,
775
+ ...(outputImageStats ? { outputImageStats } : {}),
776
+ }, {
777
+ ...linkage,
778
+ response: response.text,
779
+ responseTruncated: response.truncated,
780
+ attachments,
781
+ ...(outputImageStats ? { outputImageStats } : {}),
782
+ })
783
+ }
784
+
785
+ async fail(jobId: string, error: unknown): Promise<QueryJobMutationResult> {
786
+ const current = await this.getSnapshot(jobId)
787
+ if (current.status === 'answer_ready') {
788
+ return this.complete(jobId, {
789
+ text: current.partialText,
790
+ attachments: current.attachments,
791
+ outputImageStats: current.outputImageStats,
792
+ provider: current.provider,
793
+ resolvedModel: current.resolvedModel,
794
+ cliSessionId: current.cliSessionId,
795
+ claudeRunId: current.claudeRunId,
796
+ codexRunId: current.codexRunId,
797
+ codexThreadId: current.codexThreadId,
798
+ })
799
+ }
800
+ const normalized = normalizeQueryJobError(error)
801
+ return this.transition(jobId, 'failed', 'failed', {
802
+ error: normalized, completedAt: this.now().toISOString(),
803
+ }, { error: normalized })
804
+ }
805
+
806
+ async cancel(jobId: string, generation: number): Promise<QueryJobMutationResult> {
807
+ const current = await this.getSnapshot(jobId, generation)
808
+ if (isTerminalQueryJobStatus(current.status)) return { applied: false, job: current }
809
+ // answer_ready is the durable commit point: the provider has stopped
810
+ // generating and bridge post-processing may already be mutating the
811
+ // canonical conversation. Refusing late cancellation prevents a canceled
812
+ // journal from racing a completed conversation/display notification.
813
+ if (current.status === 'answer_ready') throw new QueryJobAnswerCommittingError()
814
+ const error: QueryJobError = { code: 'canceled', message: 'Canceled by user.' }
815
+ return this.transition(jobId, 'canceled', 'canceled', {
816
+ error, completedAt: this.now().toISOString(),
817
+ }, { error })
818
+ }
819
+
820
+ async interrupt(jobId: string, reason = 'server_interrupted'): Promise<QueryJobMutationResult> {
821
+ const current = await this.getSnapshot(jobId)
822
+ if (isTerminalQueryJobStatus(current.status)) return { applied: false, job: current }
823
+ if (current.status === 'answer_ready') {
824
+ return this.complete(jobId, {
825
+ text: current.partialText,
826
+ attachments: current.attachments,
827
+ outputImageStats: current.outputImageStats,
828
+ provider: current.provider,
829
+ resolvedModel: current.resolvedModel,
830
+ cliSessionId: current.cliSessionId,
831
+ claudeRunId: current.claudeRunId,
832
+ codexRunId: current.codexRunId,
833
+ codexThreadId: current.codexThreadId,
834
+ })
835
+ }
836
+ const error: QueryJobError = {
837
+ code: 'interrupted',
838
+ message: reason === 'server_restarted'
839
+ ? 'Server restarted. Prompt preserved; provider was not restarted.'
840
+ : sanitizeQueryJobActivity(reason).text,
841
+ retryable: true,
842
+ }
843
+ // Only a job with a persisted provider-process ledger id can own an
844
+ // orphan after this server process dies. Accepted/starting/context-build
845
+ // jobs are safe to retry immediately after restart.
846
+ // Only the explicit pre-stdin ownership barrier proves an interrupted
847
+ // child can still own this session. Fence hard restarts and graceful
848
+ // shutdowns alike; a new clientJobId must not bypass provider ownership.
849
+ const providerWasSpawned = Boolean(current.providerOwnershipConfirmedAt)
850
+ const orphanFenceUntil = providerWasSpawned
851
+ ? new Date(this.now().getTime() + QUERY_JOB_ORPHAN_FENCE_MS).toISOString()
852
+ : undefined
853
+ return this.transition(jobId, 'interrupted', 'interrupted', {
854
+ error, completedAt: this.now().toISOString(),
855
+ ...(orphanFenceUntil ? { orphanFenceUntil } : {}),
856
+ }, { error, ...(orphanFenceUntil ? { orphanFenceUntil } : {}) })
857
+ }
858
+
859
+ async acknowledge(jobId: string, generation: number): Promise<QueryJobMutationResult> {
860
+ const snapshot = await this.getSnapshot(jobId, generation)
861
+ if (!isTerminalQueryJobStatus(snapshot.status)) throw new QueryJobNotTerminalError()
862
+ if (snapshot.acknowledgedAt) return { applied: false, job: snapshot }
863
+ await this.ensureHydrated(jobId)
864
+ return this.enqueue(async () => {
865
+ this.assertWritable()
866
+ const hydrated = this.jobs.get(jobId)
867
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
868
+ if (hydrated.snapshot.acknowledgedAt) return { applied: false, job: clone(hydrated.snapshot) }
869
+ if (!isTerminalQueryJobStatus(hydrated.snapshot.status)) throw new QueryJobNotTerminalError()
870
+ const acknowledgedAt = this.now().toISOString()
871
+ return this.persistMutation(
872
+ hydrated,
873
+ hydrated.snapshot.status,
874
+ 'acknowledged',
875
+ { acknowledgedAt },
876
+ { acknowledgedAt },
877
+ )
878
+ })
879
+ }
880
+
881
+ private safeLinkage(linkage: QueryJobProviderLinkage): QueryJobProviderLinkage {
882
+ return {
883
+ ...(linkage.provider === 'claude' || linkage.provider === 'codex' ? { provider: linkage.provider } : {}),
884
+ ...(safeOptional(linkage.resolvedModel, 64) ? { resolvedModel: safeOptional(linkage.resolvedModel, 64) } : {}),
885
+ ...(safeOptional(linkage.cliSessionId) ? { cliSessionId: safeOptional(linkage.cliSessionId) } : {}),
886
+ ...(safeOptional(linkage.claudeRunId) ? { claudeRunId: safeOptional(linkage.claudeRunId) } : {}),
887
+ ...(safeOptional(linkage.codexRunId) ? { codexRunId: safeOptional(linkage.codexRunId) } : {}),
888
+ ...(safeOptional(linkage.codexThreadId) ? { codexThreadId: safeOptional(linkage.codexThreadId) } : {}),
889
+ }
890
+ }
891
+
892
+ private async transition(
893
+ jobId: string,
894
+ status: QueryJobStatus,
895
+ type: QueryJobEventType,
896
+ patch: Record<string, unknown>,
897
+ eventData: Record<string, unknown>,
898
+ ): Promise<QueryJobMutationResult> {
899
+ await this.ensureInitialized()
900
+ await this.ensureHydrated(jobId)
901
+ return this.enqueue(async () => {
902
+ this.assertWritable()
903
+ const hydrated = this.jobs.get(jobId)
904
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
905
+ if (isTerminalQueryJobStatus(hydrated.snapshot.status)) {
906
+ return { applied: false, job: clone(hydrated.snapshot) }
907
+ }
908
+ if (hydrated.snapshot.status === status || !ALLOWED_TRANSITIONS[hydrated.snapshot.status].has(status)) {
909
+ return { applied: false, job: clone(hydrated.snapshot) }
910
+ }
911
+ return this.persistMutation(hydrated, status, type, patch, eventData)
912
+ })
913
+ }
914
+
915
+ private async mutateSameStatus(
916
+ jobId: string,
917
+ type: QueryJobEventType,
918
+ patch: Record<string, unknown>,
919
+ eventData: Record<string, unknown>,
920
+ ): Promise<QueryJobMutationResult> {
921
+ await this.ensureInitialized()
922
+ await this.ensureHydrated(jobId)
923
+ return this.enqueue(async () => {
924
+ this.assertWritable()
925
+ const hydrated = this.jobs.get(jobId)
926
+ if (!hydrated) throw new QueryJobNotFoundError(jobId)
927
+ if (isTerminalQueryJobStatus(hydrated.snapshot.status)) {
928
+ return { applied: false, job: clone(hydrated.snapshot) }
929
+ }
930
+ return this.persistMutation(hydrated, hydrated.snapshot.status, type, patch, eventData)
931
+ })
932
+ }
933
+
934
+ private async persistMutation(
935
+ hydrated: HydratedQueryJob,
936
+ status: QueryJobStatus,
937
+ type: QueryJobEventType,
938
+ patch: Record<string, unknown>,
939
+ eventData: Record<string, unknown>,
940
+ ): Promise<QueryJobMutationResult> {
941
+ const snapshot = hydrated.snapshot
942
+ const persistedAt = this.now().toISOString()
943
+ const record: QueryJobJournalRecord = {
944
+ schemaVersion: QUERY_JOB_SCHEMA_VERSION,
945
+ recordId: randomUUID(),
946
+ partitionDay: localPartitionDay(this.now()),
947
+ persistedAt,
948
+ bootId: this.options.bootId,
949
+ jobId: snapshot.jobId,
950
+ clientJobId: snapshot.clientJobId,
951
+ generation: snapshot.generation,
952
+ turnId: snapshot.turnId,
953
+ requestFingerprint: snapshot.requestFingerprint,
954
+ eventSeq: snapshot.eventSeq + 1,
955
+ type,
956
+ status,
957
+ patch,
958
+ eventData,
959
+ }
960
+ await this.appendRecord(record)
961
+ const event = this.applyRecord(record, true)
962
+ if (!event) throw new QueryJobStoreError('query_job_reducer_rejected_persisted_record')
963
+ return { applied: true, job: clone(hydrated.snapshot), event: clone(event) }
964
+ }
965
+
966
+ private async ensureHydrated(jobId: string): Promise<void> {
967
+ if (this.jobs.has(jobId)) return
968
+ if (!this.identitiesByJobId.has(jobId)) throw new QueryJobNotFoundError(jobId)
969
+ for (const partition of this.partitions) await this.hydratePartition(partition, jobId)
970
+ if (!this.jobs.has(jobId)) throw new QueryJobNotFoundError(jobId)
971
+ this.trimHydratedJobs(jobId)
972
+ }
973
+
974
+ async getSnapshot(jobId: string, generation?: number): Promise<QueryJobSnapshot> {
975
+ await this.ensureInitialized()
976
+ await this.ensureHydrated(jobId)
977
+ const snapshot = this.jobs.get(jobId)!.snapshot
978
+ if (generation != null && snapshot.generation !== generation) {
979
+ throw new QueryJobGenerationMismatchError(jobId, snapshot.generation, generation)
980
+ }
981
+ return clone(snapshot)
982
+ }
983
+
984
+ async getExecution(jobId: string): Promise<QueryJobExecutionRecord> {
985
+ await this.ensureInitialized()
986
+ await this.ensureHydrated(jobId)
987
+ const hydrated = this.jobs.get(jobId)!
988
+ return { request: clone(hydrated.request), job: clone(hydrated.snapshot) }
989
+ }
990
+
991
+ /** Enumerate retained identities for boot-time projection repair. Hydration
992
+ * remains bounded: each record is cloned and the normal LRU trim can evict
993
+ * earlier terminal jobs as the scan advances. */
994
+ async listRetainedExecutions(): Promise<QueryJobExecutionRecord[]> {
995
+ await this.ensureInitialized()
996
+ const out: QueryJobExecutionRecord[] = []
997
+ for (const jobId of [...this.identitiesByJobId.keys()]) {
998
+ try {
999
+ out.push(await this.getExecution(jobId))
1000
+ } catch (error) {
1001
+ if (!(error instanceof QueryJobNotFoundError)) throw error
1002
+ }
1003
+ }
1004
+ return out
1005
+ }
1006
+
1007
+ async findByClientGeneration(clientJobId: string, generation: number): Promise<QueryJobSnapshot | undefined> {
1008
+ await this.ensureInitialized()
1009
+ const identity = this.identitiesByKey.get(identityKey(clientJobId.toLowerCase(), generation))
1010
+ if (!identity) return undefined
1011
+ return this.getSnapshot(identity.jobId, generation)
1012
+ }
1013
+
1014
+ async replay(jobId: string, generation: number, after: number): Promise<QueryJobReplay> {
1015
+ await this.getSnapshot(jobId, generation)
1016
+ return this.buildReplay(jobId, after)
1017
+ }
1018
+
1019
+ private buildReplay(jobId: string, after: number): QueryJobReplay {
1020
+ const hydrated = this.jobs.get(jobId)!
1021
+ const snapshot = clone(hydrated.snapshot)
1022
+ const oldestEventSeq = hydrated.events[0]?.eventSeq ?? snapshot.eventSeq + 1
1023
+ const latestEventSeq = snapshot.eventSeq
1024
+ if (after > latestEventSeq) {
1025
+ return { events: [], gap: true, reason: 'cursor_ahead', oldestEventSeq, latestEventSeq, snapshot }
1026
+ }
1027
+ if (after < oldestEventSeq - 1) {
1028
+ return { events: [], gap: true, reason: 'buffer_overflow', oldestEventSeq, latestEventSeq, snapshot }
1029
+ }
1030
+ return {
1031
+ events: clone(hydrated.events.filter(event => event.eventSeq > after)),
1032
+ gap: false,
1033
+ oldestEventSeq,
1034
+ latestEventSeq,
1035
+ snapshot,
1036
+ }
1037
+ }
1038
+
1039
+ async subscribe(
1040
+ jobId: string,
1041
+ generation: number,
1042
+ after: number,
1043
+ listener: (event: QueryJobEvent) => void,
1044
+ ): Promise<QueryJobSubscription> {
1045
+ await this.getSnapshot(jobId, generation)
1046
+ let closed = false
1047
+ const wrapped = (event: QueryJobEvent) => {
1048
+ if (!closed && event.generation === generation && event.eventSeq > after) listener(clone(event))
1049
+ }
1050
+ this.emitter.on(jobId, wrapped)
1051
+ this.subscriberCount++
1052
+ this.refreshHealth()
1053
+ // Register first, then take the synchronous replay snapshot. An append can
1054
+ // therefore be in replay OR arrive live (and clients dedupe by eventSeq),
1055
+ // but can never fall into an await-sized gap between the two.
1056
+ const replay = this.buildReplay(jobId, after)
1057
+ return {
1058
+ replay,
1059
+ unsubscribe: () => {
1060
+ if (closed) return
1061
+ closed = true
1062
+ this.emitter.off(jobId, wrapped)
1063
+ this.subscriberCount = Math.max(0, this.subscriberCount - 1)
1064
+ this.refreshHealth()
1065
+ },
1066
+ }
1067
+ }
1068
+
1069
+ private trimHydratedJobs(protectedJobId?: string): void {
1070
+ if (this.jobs.size <= this.maxHydratedJobs) return
1071
+ const candidates = [...this.jobs.values()]
1072
+ .filter(job => job.snapshot.jobId !== protectedJobId)
1073
+ .sort((a, b) => {
1074
+ const aTerminal = isTerminalQueryJobStatus(a.snapshot.status) ? 0 : 1
1075
+ const bTerminal = isTerminalQueryJobStatus(b.snapshot.status) ? 0 : 1
1076
+ return aTerminal - bTerminal || a.snapshot.updatedAt.localeCompare(b.snapshot.updatedAt)
1077
+ })
1078
+ while (this.jobs.size > this.maxHydratedJobs && candidates.length > 0) {
1079
+ const evicted = candidates.shift()!
1080
+ this.jobs.delete(evicted.snapshot.jobId)
1081
+ this.health.evictedHydratedJobs++
1082
+ }
1083
+ this.refreshHealth()
1084
+ }
1085
+
1086
+ private refreshHealth(): void {
1087
+ const counts = {
1088
+ accepted: 0, starting: 0, running: 0, answer_ready: 0,
1089
+ completed: 0, failed: 0, canceled: 0, interrupted: 0,
1090
+ } satisfies Record<QueryJobStatus, number>
1091
+ for (const identity of this.identitiesByJobId.values()) counts[identity.status]++
1092
+ this.health.hydratedJobs = this.jobs.size
1093
+ this.health.retainedIdentities = this.identitiesByJobId.size
1094
+ this.health.subscribers = this.subscriberCount
1095
+ this.health.counts = counts
1096
+ }
1097
+
1098
+ getHealth(): QueryJobStoreHealth {
1099
+ this.refreshHealth()
1100
+ return clone(this.health)
1101
+ }
1102
+ }