@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,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
|
+
}
|