@gotcos/glasses-server 6.3.1 → 6.6.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.
Files changed (37) hide show
  1. package/.env.example +23 -7
  2. package/CHANGELOG.md +108 -0
  3. package/README.md +28 -8
  4. package/bin/cli.cjs +22 -10
  5. package/package.json +18 -6
  6. package/server/bin/cos-output-image-publisher.mjs +324 -0
  7. package/server/bootstrap.ts +16 -0
  8. package/server/index.ts +61 -21
  9. package/server/lib/activity-preview.ts +168 -0
  10. package/server/lib/archive.ts +20 -6
  11. package/server/lib/claude-bridge.ts +215 -60
  12. package/server/lib/claude-run-ledger.ts +7 -2
  13. package/server/lib/codex-bridge.ts +186 -71
  14. package/server/lib/codex-engine-sessions.ts +24 -2
  15. package/server/lib/codex-model-catalog.ts +450 -0
  16. package/server/lib/codex-run-ledger.ts +20 -4
  17. package/server/lib/conversation.ts +64 -2
  18. package/server/lib/display-bus.ts +61 -3
  19. package/server/lib/image-safety.ts +458 -0
  20. package/server/lib/listener-startup.ts +29 -0
  21. package/server/lib/media-store.ts +833 -0
  22. package/server/lib/model-image-input.ts +27 -0
  23. package/server/lib/model-router.ts +67 -8
  24. package/server/lib/query-attachments.ts +132 -0
  25. package/server/lib/run-output-images.ts +442 -0
  26. package/server/lib/server-instance-id.ts +55 -0
  27. package/server/lib/server-instance-lock.ts +122 -0
  28. package/server/lib/server-metrics.ts +7 -0
  29. package/server/routes/display.ts +43 -22
  30. package/server/routes/health.ts +19 -2
  31. package/server/routes/media.ts +285 -0
  32. package/server/routes/message-ref.ts +18 -6
  33. package/server/routes/openai-compat.ts +44 -11
  34. package/server/routes/query.ts +51 -16
  35. package/server/routes/sessions.ts +33 -4
  36. package/shared/media-attachment.ts +126 -0
  37. package/shared/model-preference.ts +140 -17
@@ -0,0 +1,833 @@
1
+ // Durable media store — the server-side home for image attachments.
2
+ // Layout (all under server/data/media/, dirs 0700 / files 0600):
3
+ // index.json — metadata index (atomic writes)
4
+ // assets/<media-id>/original-normalized.jpg — normalized phone asset
5
+ // assets/<media-id>/thumb.jpg — thumbnail
6
+ // assets/<media-id>/g2-N.pbm — RESERVED names; not generated
7
+ // until Release B picks a
8
+ // hardware-proven layout.
9
+ //
10
+ // Invariants:
11
+ // * Published media is immutable — bytes and intrinsic metadata are never
12
+ // replaced in place for an id a message can see.
13
+ // * The public ref (shared/media-attachment.ts) never exposes storage paths;
14
+ // lifecycle and paths live only in this index.
15
+ // * All index mutations serialize through one promise chain.
16
+ // * Uploads stage in tmp/, validate + normalize, then rename into assets/
17
+ // and publish the index record — readers never see a half-written asset.
18
+ // * Reservation/association are idempotent; replays can't duplicate or
19
+ // prematurely release an asset. Associate wins over a delayed release.
20
+
21
+ import { createHash, randomBytes } from 'node:crypto'
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ renameSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ } from 'node:fs'
31
+ import { join, resolve } from 'node:path'
32
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
33
+ import { dataPath } from './data-dir.js'
34
+ import {
35
+ isValidMediaId,
36
+ parseMediaAttachmentRef,
37
+ type MediaAttachmentRef,
38
+ type MediaKind,
39
+ } from '../../shared/media-attachment.js'
40
+ import {
41
+ G2_VARIANT_H,
42
+ G2_VARIANT_W,
43
+ ImageSafetyError,
44
+ normalizeImage,
45
+ normalizeOutputArtifact,
46
+ parsePngDimensions,
47
+ renderG2Variant,
48
+ sniffImageType,
49
+ validateSourceImage,
50
+ } from './image-safety.js'
51
+
52
+ // Standalone state belongs under the same durable data root as conversations,
53
+ // archives, and run ledgers. COS_MEDIA_ROOT remains an explicit escape hatch
54
+ // for operators who keep high-volume image bytes on a separate local volume.
55
+ const DEFAULT_MEDIA_ROOT = process.env.COS_MEDIA_ROOT
56
+ ? resolve(process.env.COS_MEDIA_ROOT)
57
+ : dataPath('media')
58
+
59
+ // ── Retention policy (Release A contract) ────────────────────────────────────
60
+
61
+ export const STAGED_TTL_MS = 4 * 60 * 60_000 // unsubmitted uploads
62
+ export const RESERVED_TTL_MS = 7 * 24 * 60 * 60_000 // queued, not yet run
63
+ export const TRAFFIC_CONTENT_TTL_MS = 10 * 60_000 // traffic frame bytes
64
+ export const GENERATED_CONTENT_TTL_MS = 30 * 24 * 60 * 60_000 // agent-selected lens cache
65
+ const TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60_000 // expired/deleted rows
66
+ export const MEDIA_GC_INTERVAL_MS = 5 * 60_000
67
+ export const G2_LENS_VARIANT_CAPABILITY = 'png-288x144-v1'
68
+
69
+ // macOS File Provider/iCloud can transiently reject an otherwise-valid
70
+ // same-volume directory rename with errno -11 (EDEADLK). That exact failure
71
+ // surfaced during the first live output-image canary. Atomic publication is
72
+ // still the right boundary; retry the rename itself for a short bounded
73
+ // window instead of weakening it to copy-then-delete.
74
+ const ATOMIC_RENAME_RETRY_DELAYS_MS = [25, 75, 225, 675] as const
75
+
76
+ type RenameLike = (source: string, target: string) => void
77
+ type WaitLike = (ms: number) => Promise<void>
78
+
79
+ function isTransientAtomicRenameError(err: unknown): boolean {
80
+ if (!err || typeof err !== 'object') return false
81
+ const value = err as { code?: unknown; errno?: unknown; message?: unknown }
82
+ if (value.errno === -11 || value.errno === -35 || value.errno === -16 || value.errno === -4) return true
83
+ const code = typeof value.code === 'string' ? value.code : ''
84
+ if (code === 'EDEADLK' || code === 'EAGAIN' || code === 'EBUSY' || code === 'EINTR') return true
85
+ return typeof value.message === 'string' && /Unknown system error -(?:11|35|16|4)\b/.test(value.message)
86
+ }
87
+
88
+ async function renameWithTransientRetry(
89
+ source: string,
90
+ target: string,
91
+ rename: RenameLike = renameSync,
92
+ wait: WaitLike = (ms) => new Promise((resolveWait) => setTimeout(resolveWait, ms)),
93
+ ): Promise<void> {
94
+ for (let attempt = 0; ; attempt++) {
95
+ try {
96
+ rename(source, target)
97
+ return
98
+ } catch (err) {
99
+ const delay = ATOMIC_RENAME_RETRY_DELAYS_MS[attempt]
100
+ if (delay === undefined || !isTransientAtomicRenameError(err)) throw err
101
+ console.warn(`[media-store] transient atomic rename failure; retrying in ${delay}ms`)
102
+ await wait(delay)
103
+ }
104
+ }
105
+ }
106
+
107
+ /** Test seam for the File Provider rename recovery contract. */
108
+ export async function _renameWithTransientRetryForTests(
109
+ source: string,
110
+ target: string,
111
+ rename: RenameLike,
112
+ wait: WaitLike = async () => {},
113
+ ): Promise<void> {
114
+ await renameWithTransientRetry(source, target, rename, wait)
115
+ }
116
+
117
+ export type MediaLifecycle = 'staged' | 'reserved' | 'associated' | 'expired' | 'deleted'
118
+
119
+ export interface MediaRecord {
120
+ ref: MediaAttachmentRef
121
+ /** Relative to the media root. Never exposed through the API. */
122
+ storagePath: string
123
+ thumbPath: string
124
+ bytes: number
125
+ sha256: string
126
+ lifecycle: MediaLifecycle
127
+ /** True once asset bytes were removed (content TTL or GC) while the
128
+ * metadata record remains (e.g. expired traffic frames). */
129
+ contentRemoved?: boolean
130
+ sessionId?: string
131
+ clientQueueItemId?: string
132
+ runId?: string
133
+ globalMsgNum?: number
134
+ createdAtMs: number
135
+ updatedAtMs: number
136
+ reservedAtMs?: number
137
+ associatedAtMs?: number
138
+ }
139
+
140
+ interface MediaIndexFile {
141
+ v: 1
142
+ records: Record<string, MediaRecord>
143
+ savedAt: string
144
+ }
145
+
146
+ export type MediaStoreErrorCode =
147
+ | 'media_not_found'
148
+ | 'media_expired'
149
+ | 'media_deleted'
150
+ | 'media_unavailable'
151
+ | 'media_conflict'
152
+
153
+ export class MediaStoreError extends Error {
154
+ readonly code: MediaStoreErrorCode
155
+ constructor(code: MediaStoreErrorCode, message: string) {
156
+ super(message)
157
+ this.code = code
158
+ this.name = 'MediaStoreError'
159
+ }
160
+ }
161
+
162
+ export interface IngestInput {
163
+ bytes: Buffer
164
+ kind: MediaKind
165
+ label?: string
166
+ capturedAt?: string
167
+ sessionId?: string
168
+ }
169
+
170
+ export type MediaContentResult =
171
+ | { status: 'ok'; path: string; mime: MediaAttachmentRef['mime']; bytes: number }
172
+ | { status: 'not_found' }
173
+ | { status: 'expired' }
174
+ | { status: 'unavailable' }
175
+
176
+ function sanitizeRecord(raw: unknown): MediaRecord | null {
177
+ if (!raw || typeof raw !== 'object') return null
178
+ const r = raw as Record<string, unknown>
179
+ const ref = parseMediaAttachmentRef(r.ref)
180
+ if (!ref) return null
181
+ if (typeof r.storagePath !== 'string' || typeof r.thumbPath !== 'string') return null
182
+ const lifecycle = r.lifecycle
183
+ if (lifecycle !== 'staged' && lifecycle !== 'reserved' && lifecycle !== 'associated' &&
184
+ lifecycle !== 'expired' && lifecycle !== 'deleted') return null
185
+ // Paths are derived from the strictly validated id — reject drift.
186
+ const expectedDir = join('assets', ref.id)
187
+ if (!r.storagePath.startsWith(expectedDir) || !r.thumbPath.startsWith(expectedDir)) return null
188
+ return {
189
+ ref,
190
+ storagePath: r.storagePath,
191
+ thumbPath: r.thumbPath,
192
+ bytes: typeof r.bytes === 'number' && r.bytes >= 0 ? r.bytes : 0,
193
+ sha256: typeof r.sha256 === 'string' ? r.sha256 : '',
194
+ lifecycle,
195
+ contentRemoved: r.contentRemoved === true,
196
+ ...(typeof r.sessionId === 'string' ? { sessionId: r.sessionId } : {}),
197
+ ...(typeof r.clientQueueItemId === 'string' ? { clientQueueItemId: r.clientQueueItemId } : {}),
198
+ ...(typeof r.runId === 'string' ? { runId: r.runId } : {}),
199
+ ...(typeof r.globalMsgNum === 'number' ? { globalMsgNum: r.globalMsgNum } : {}),
200
+ createdAtMs: typeof r.createdAtMs === 'number' ? r.createdAtMs : Date.now(),
201
+ updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
202
+ ...(typeof r.reservedAtMs === 'number' ? { reservedAtMs: r.reservedAtMs } : {}),
203
+ ...(typeof r.associatedAtMs === 'number' ? { associatedAtMs: r.associatedAtMs } : {}),
204
+ }
205
+ }
206
+
207
+ export class MediaStore {
208
+ private readonly root: string
209
+ private readonly records = new Map<string, MediaRecord>()
210
+ private readonly renderLensVariant: typeof renderG2Variant
211
+ /** One cold-cache render per media id. Callers share the same promise. */
212
+ private readonly g2InFlight = new Map<string, Promise<MediaContentResult>>()
213
+ /** A corrupt/unreadable index makes the asset directory authoritative only
214
+ * for recovery. Never classify its entries as disposable orphans that boot. */
215
+ private allowOrphanCleanup = true
216
+ // ONE promise chain serializes every index mutation.
217
+ private mutationChain: Promise<unknown> = Promise.resolve()
218
+ private gcTimer: ReturnType<typeof setInterval> | null = null
219
+
220
+ constructor(
221
+ root: string = DEFAULT_MEDIA_ROOT,
222
+ dependencies: { renderG2Variant?: typeof renderG2Variant } = {},
223
+ ) {
224
+ this.root = root
225
+ this.renderLensVariant = dependencies.renderG2Variant ?? renderG2Variant
226
+ this.ensureDirs()
227
+ this.loadIndex()
228
+ this.reconcile()
229
+ }
230
+
231
+ // ── Filesystem layout ──────────────────────────────────────────────────────
232
+
233
+ private ensureDirs(): void {
234
+ mkdirSync(this.root, { recursive: true, mode: 0o700 })
235
+ mkdirSync(join(this.root, 'assets'), { recursive: true, mode: 0o700 })
236
+ mkdirSync(join(this.root, 'tmp'), { recursive: true, mode: 0o700 })
237
+ }
238
+
239
+ private indexPath(): string {
240
+ return join(this.root, 'index.json')
241
+ }
242
+
243
+ private absPath(rel: string): string {
244
+ return join(this.root, rel)
245
+ }
246
+
247
+ // ── Index persistence ──────────────────────────────────────────────────────
248
+
249
+ private loadIndex(): void {
250
+ const path = this.indexPath()
251
+ const result = loadJsonOrQuarantine<MediaIndexFile>(path)
252
+ if (result.status === 'missing') {
253
+ // loadJsonOrQuarantine also reports transient read/permission failures as
254
+ // missing. If the file still exists, fail safe instead of deleting every
255
+ // asset because an unreadable index yielded zero records.
256
+ if (existsSync(path)) {
257
+ this.allowOrphanCleanup = false
258
+ console.error(
259
+ '[media-store] media index exists but could not be read. ' +
260
+ 'Asset dirs are preserved and orphan cleanup is disabled this boot.',
261
+ )
262
+ } else {
263
+ // A missing index beside surviving asset dirs can also be the result of
264
+ // interrupted recovery. Prefer a small storage leak to unrecoverable
265
+ // deletion; a healthy empty store has no asset entries to preserve.
266
+ try {
267
+ if (readdirSync(join(this.root, 'assets')).length > 0) {
268
+ this.allowOrphanCleanup = false
269
+ console.error(
270
+ '[media-store] media index is missing while asset dirs exist. ' +
271
+ 'Asset dirs are preserved and orphan cleanup is disabled this boot.',
272
+ )
273
+ }
274
+ } catch { /* reconcile will independently tolerate an unreadable asset dir */ }
275
+ }
276
+ return
277
+ }
278
+ if (result.status === 'corrupt') {
279
+ this.allowOrphanCleanup = false
280
+ console.error(
281
+ `[media-store] CORRUPT media index quarantined to ${result.quarantinedAs}. ` +
282
+ 'Starting with an empty index; asset dirs are preserved and orphan cleanup is disabled this boot.',
283
+ result.error,
284
+ )
285
+ return
286
+ }
287
+ if (!result.data || typeof result.data !== 'object' || result.data.v !== 1 ||
288
+ !result.data.records || typeof result.data.records !== 'object' ||
289
+ Array.isArray(result.data.records)) {
290
+ this.allowOrphanCleanup = false
291
+ const requestedQuarantinePath = `${path}.corrupt-${Date.now()}`
292
+ let quarantinedAs = path
293
+ try {
294
+ renameSync(path, requestedQuarantinePath)
295
+ quarantinedAs = requestedQuarantinePath
296
+ } catch { /* preserve in place if quarantine fails */ }
297
+ console.error(
298
+ `[media-store] INVALID media index quarantined to ${quarantinedAs}. ` +
299
+ 'Asset dirs are preserved and orphan cleanup is disabled this boot.',
300
+ )
301
+ return
302
+ }
303
+ let invalidRecords = 0
304
+ for (const raw of Object.values(result.data.records)) {
305
+ const rec = sanitizeRecord(raw)
306
+ if (rec) this.records.set(rec.ref.id, rec)
307
+ else invalidRecords++
308
+ }
309
+ if (invalidRecords > 0) {
310
+ this.allowOrphanCleanup = false
311
+ const requestedQuarantinePath = `${path}.corrupt-${Date.now()}`
312
+ let quarantinedAs = path
313
+ try {
314
+ renameSync(path, requestedQuarantinePath)
315
+ quarantinedAs = requestedQuarantinePath
316
+ } catch { /* preserve in place if quarantine fails */ }
317
+ console.error(
318
+ `[media-store] media index contained ${invalidRecords} invalid record(s); ` +
319
+ `quarantined to ${quarantinedAs}. Asset dirs are preserved and orphan cleanup is disabled this boot.`,
320
+ )
321
+ }
322
+ }
323
+
324
+ private saveIndex(): void {
325
+ const data: MediaIndexFile = {
326
+ v: 1,
327
+ records: Object.fromEntries(this.records),
328
+ savedAt: new Date().toISOString(),
329
+ }
330
+ atomicWriteFileSync(this.indexPath(), JSON.stringify(data, null, 2))
331
+ }
332
+
333
+ /** Serialize an index mutation. All lifecycle transitions go through here. */
334
+ private withLock<T>(op: () => T | Promise<T>): Promise<T> {
335
+ const run = this.mutationChain.then(op, op)
336
+ this.mutationChain = run.then(() => undefined, () => undefined)
337
+ return run
338
+ }
339
+
340
+ // ── Boot reconciliation ────────────────────────────────────────────────────
341
+
342
+ /** Quarantine-free reconcile: drop tmp leftovers, remove unpublished orphan
343
+ * asset dirs, and flag indexed records whose content is missing. Never
344
+ * throws — one bad asset must not take the server down. */
345
+ private reconcile(): void {
346
+ try {
347
+ rmSync(join(this.root, 'tmp'), { recursive: true, force: true })
348
+ mkdirSync(join(this.root, 'tmp'), { recursive: true, mode: 0o700 })
349
+ } catch { /* best effort */ }
350
+ if (this.allowOrphanCleanup) {
351
+ try {
352
+ for (const entry of readdirSync(join(this.root, 'assets'))) {
353
+ try {
354
+ if (!this.records.has(entry)) {
355
+ // Unpublished orphan — the upload died between rename and index
356
+ // publish. Remove; the client never received this id.
357
+ rmSync(join(this.root, 'assets', entry), { recursive: true, force: true })
358
+ console.warn(`[media-store] removed unpublished orphan asset ${entry}`)
359
+ }
360
+ } catch { /* skip this asset */ }
361
+ }
362
+ } catch { /* assets dir unreadable — leave for next boot */ }
363
+ } else {
364
+ console.warn('[media-store] preserving unindexed asset dirs for recovery this boot')
365
+ }
366
+ let dirty = false
367
+ for (const rec of this.records.values()) {
368
+ try {
369
+ if (!rec.contentRemoved && rec.lifecycle !== 'deleted' && rec.lifecycle !== 'expired' &&
370
+ !existsSync(this.absPath(rec.storagePath))) {
371
+ rec.contentRemoved = true
372
+ rec.updatedAtMs = Date.now()
373
+ dirty = true
374
+ console.warn(`[media-store] indexed content missing for ${rec.ref.id} — marked unavailable`)
375
+ }
376
+ } catch { /* skip */ }
377
+ }
378
+ if (dirty) {
379
+ try { this.saveIndex() } catch (err) { console.error('[media-store] reconcile save failed:', err) }
380
+ }
381
+ }
382
+
383
+ // ── Ingestion ──────────────────────────────────────────────────────────────
384
+
385
+ async ingestImage(input: IngestInput): Promise<MediaAttachmentRef> {
386
+ // Validate + normalize OUTSIDE the index lock (CPU/subprocess-heavy).
387
+ const validated = validateSourceImage(input.bytes)
388
+ const normalized = await normalizeImage(validated)
389
+ return this.publishNormalizedImage(input, normalized)
390
+ }
391
+
392
+ /** Trusted-local agent artifact ingress. Unlike the public upload path,
393
+ * this accepts a bounded larger JPEG/PNG/WebP/HEIC/AVIF and immediately
394
+ * converts it into the same normalized JPEG contract before publication. */
395
+ async ingestOutputImage(input: IngestInput): Promise<MediaAttachmentRef> {
396
+ if (input.kind !== 'generated_visual') {
397
+ throw new ImageSafetyError('unsupported_format', 'output artifact ingress is generated_visual only')
398
+ }
399
+ const normalized = await normalizeOutputArtifact(input.bytes)
400
+ return this.publishNormalizedImage(input, normalized)
401
+ }
402
+
403
+ private async publishNormalizedImage(
404
+ input: IngestInput,
405
+ normalized: Awaited<ReturnType<typeof normalizeImage>>,
406
+ ): Promise<MediaAttachmentRef> {
407
+ const id = `m_${randomBytes(12).toString('hex')}`
408
+ const now = Date.now()
409
+ const nowIso = new Date(now).toISOString()
410
+ const ref: MediaAttachmentRef = {
411
+ id,
412
+ kind: input.kind,
413
+ mime: normalized.mime,
414
+ width: normalized.width,
415
+ height: normalized.height,
416
+ createdAt: nowIso,
417
+ ...(input.label ? { label: input.label.slice(0, 120) } : {}),
418
+ ...(input.capturedAt ? { capturedAt: input.capturedAt } : {}),
419
+ ...(input.kind === 'traffic_frame'
420
+ ? { expiresAt: new Date(now + TRAFFIC_CONTENT_TTL_MS).toISOString() }
421
+ : input.kind === 'generated_visual'
422
+ ? { expiresAt: new Date(now + GENERATED_CONTENT_TTL_MS).toISOString() }
423
+ : {}),
424
+ }
425
+
426
+ // Stage in tmp/<id>/, then atomically rename into assets/<id>/.
427
+ const stageDir = join(this.root, 'tmp', id)
428
+ mkdirSync(stageDir, { recursive: true, mode: 0o700 })
429
+ try {
430
+ writeFileSync(join(stageDir, 'original-normalized.jpg'), normalized.normalized, { mode: 0o600 })
431
+ writeFileSync(join(stageDir, 'thumb.jpg'), normalized.thumb, { mode: 0o600 })
432
+ await renameWithTransientRetry(stageDir, join(this.root, 'assets', id))
433
+ } catch (err) {
434
+ try { rmSync(stageDir, { recursive: true, force: true }) } catch { /* ignore */ }
435
+ throw err
436
+ }
437
+
438
+ const record: MediaRecord = {
439
+ ref,
440
+ storagePath: join('assets', id, 'original-normalized.jpg'),
441
+ thumbPath: join('assets', id, 'thumb.jpg'),
442
+ bytes: normalized.normalized.length,
443
+ sha256: createHash('sha256').update(normalized.normalized).digest('hex'),
444
+ lifecycle: 'staged',
445
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
446
+ createdAtMs: now,
447
+ updatedAtMs: now,
448
+ }
449
+ await this.withLock(() => {
450
+ this.records.set(id, record)
451
+ this.saveIndex()
452
+ })
453
+ return ref
454
+ }
455
+
456
+ // ── Lookups (read-only, no lock needed) ───────────────────────────────────
457
+
458
+ getRecord(id: string): MediaRecord | null {
459
+ if (!isValidMediaId(id)) return null
460
+ return this.records.get(id) ?? null
461
+ }
462
+
463
+ getRef(id: string): MediaAttachmentRef | null {
464
+ return this.getRecord(id)?.ref ?? null
465
+ }
466
+
467
+ /** Resolve content for serving/model input, honoring lifecycle + TTLs. */
468
+ getContent(id: string, variant: 'phone' | 'thumb' | 'g2' = 'phone'): MediaContentResult {
469
+ const rec = this.getRecord(id)
470
+ if (!rec || rec.lifecycle === 'deleted') return { status: 'not_found' }
471
+ if (rec.lifecycle === 'expired' || this.isContentExpired(rec)) return { status: 'expired' }
472
+ // TTL-controlled bytes report 'expired'; reserve 'unavailable' for
473
+ // genuinely missing/corrupt durable content.
474
+ if (rec.contentRemoved && (rec.ref.kind === 'traffic_frame' || rec.ref.kind === 'generated_visual')) {
475
+ return { status: 'expired' }
476
+ }
477
+ if (rec.contentRemoved) return { status: 'unavailable' }
478
+ if (variant === 'g2') {
479
+ // Lens variant is generated lazily by getG2Content (async); this sync
480
+ // path only reports an already-cached file.
481
+ const g2Path = this.absPath(join('assets', rec.ref.id, 'g2-288.png'))
482
+ if (!existsSync(g2Path)) return { status: 'unavailable' }
483
+ try {
484
+ if (!this.isValidG2Variant(readFileSync(g2Path))) return { status: 'unavailable' }
485
+ } catch {
486
+ return { status: 'unavailable' }
487
+ }
488
+ return { status: 'ok', path: g2Path, mime: 'image/png' as MediaAttachmentRef['mime'], bytes: 0 }
489
+ }
490
+ const rel = variant === 'thumb' ? rec.thumbPath : rec.storagePath
491
+ const path = this.absPath(rel)
492
+ if (!existsSync(path)) return { status: 'unavailable' }
493
+ return { status: 'ok', path, mime: rec.ref.mime, bytes: variant === 'thumb' ? 0 : rec.bytes }
494
+ }
495
+
496
+ /** Release B — resolve the on-lens variant, generating and caching it on
497
+ * first request (exact 288x144 grayscale PNG; see renderG2Variant). The
498
+ * cached file lives beside the asset and follows its lifecycle (the whole
499
+ * asset dir is removed together). */
500
+ async getG2Content(id: string): Promise<MediaContentResult> {
501
+ const rec = this.getRecord(id)
502
+ if (!rec || rec.lifecycle === 'deleted') return { status: 'not_found' }
503
+ if (rec.lifecycle === 'expired' || this.isContentExpired(rec)) return { status: 'expired' }
504
+ if (rec.contentRemoved) {
505
+ return rec.ref.kind === 'traffic_frame' || rec.ref.kind === 'generated_visual'
506
+ ? { status: 'expired' }
507
+ : { status: 'unavailable' }
508
+ }
509
+ const g2Path = this.absPath(join('assets', rec.ref.id, 'g2-288.png'))
510
+ if (existsSync(g2Path)) {
511
+ try {
512
+ if (this.isValidG2Variant(readFileSync(g2Path))) {
513
+ return { status: 'ok', path: g2Path, mime: 'image/png' as MediaAttachmentRef['mime'], bytes: 0 }
514
+ }
515
+ } catch { /* repair below */ }
516
+ }
517
+ const srcPath = this.absPath(rec.storagePath)
518
+ if (!existsSync(srcPath)) return { status: 'unavailable' }
519
+
520
+ const existing = this.g2InFlight.get(id)
521
+ if (existing) return existing
522
+ const generation = Promise.resolve().then(() => this.generateOrRepairG2Content(id))
523
+ this.g2InFlight.set(id, generation)
524
+ try {
525
+ return await generation
526
+ } finally {
527
+ if (this.g2InFlight.get(id) === generation) this.g2InFlight.delete(id)
528
+ }
529
+ }
530
+
531
+ private isValidG2Variant(bytes: Buffer): boolean {
532
+ if (sniffImageType(bytes) !== 'image/png') return false
533
+ const dims = parsePngDimensions(bytes)
534
+ return dims?.width === G2_VARIANT_W && dims.height === G2_VARIANT_H
535
+ }
536
+
537
+ private async generateOrRepairG2Content(id: string): Promise<MediaContentResult> {
538
+ const rec = this.getRecord(id)
539
+ if (!rec || rec.lifecycle === 'deleted') return { status: 'not_found' }
540
+ if (rec.lifecycle === 'expired' || this.isContentExpired(rec)) return { status: 'expired' }
541
+ if (rec.contentRemoved) {
542
+ return rec.ref.kind === 'traffic_frame' || rec.ref.kind === 'generated_visual'
543
+ ? { status: 'expired' }
544
+ : { status: 'unavailable' }
545
+ }
546
+
547
+ const g2Path = this.absPath(join('assets', rec.ref.id, 'g2-288.png'))
548
+ if (existsSync(g2Path)) {
549
+ try {
550
+ if (this.isValidG2Variant(readFileSync(g2Path))) {
551
+ return { status: 'ok', path: g2Path, mime: 'image/png' as MediaAttachmentRef['mime'], bytes: 0 }
552
+ }
553
+ } catch { /* remove and regenerate below */ }
554
+ try { rmSync(g2Path, { force: true }) } catch { /* atomic rename can still replace it */ }
555
+ console.warn(`[media-store] invalid G2 cache for ${id} — regenerating`)
556
+ }
557
+
558
+ const srcPath = this.absPath(rec.storagePath)
559
+ if (!existsSync(srcPath)) return { status: 'unavailable' }
560
+ let tmpPath: string | null = null
561
+ try {
562
+ const g2 = await this.renderLensVariant(readFileSync(srcPath))
563
+ if (!this.isValidG2Variant(g2)) {
564
+ throw new ImageSafetyError(
565
+ 'normalization_failed',
566
+ `G2 renderer returned a payload other than ${G2_VARIANT_W}x${G2_VARIANT_H} PNG`,
567
+ )
568
+ }
569
+
570
+ // Rendering is asynchronous. Re-check lifecycle before publishing so a
571
+ // concurrent delete/GC cannot resurrect bytes into a removed asset dir.
572
+ const current = this.getRecord(id)
573
+ if (!current || current.lifecycle === 'deleted') return { status: 'not_found' }
574
+ if (current.lifecycle === 'expired' || this.isContentExpired(current)) return { status: 'expired' }
575
+ if (current.contentRemoved || current.storagePath !== rec.storagePath || !existsSync(srcPath)) {
576
+ return current.ref.kind === 'traffic_frame' || current.ref.kind === 'generated_visual'
577
+ ? { status: 'expired' }
578
+ : { status: 'unavailable' }
579
+ }
580
+
581
+ // Atomic publish. The random private tmp name also remains safe if two
582
+ // server processes briefly overlap during a deployment.
583
+ tmpPath = join(this.root, 'tmp', `g2-${rec.ref.id}-${randomBytes(6).toString('hex')}.png`)
584
+ writeFileSync(tmpPath, g2, { mode: 0o600 })
585
+ renameSync(tmpPath, g2Path)
586
+ tmpPath = null
587
+ return { status: 'ok', path: g2Path, mime: 'image/png' as MediaAttachmentRef['mime'], bytes: g2.length }
588
+ } catch (err) {
589
+ console.error(`[media-store] G2 variant render failed for ${id}:`, err)
590
+ return { status: 'unavailable' }
591
+ } finally {
592
+ if (tmpPath) {
593
+ try { rmSync(tmpPath, { force: true }) } catch { /* best effort */ }
594
+ }
595
+ }
596
+ }
597
+
598
+ private isContentExpired(rec: MediaRecord, now = Date.now()): boolean {
599
+ if (rec.ref.kind === 'traffic_frame' && now - rec.createdAtMs > TRAFFIC_CONTENT_TTL_MS) return true
600
+ if (rec.ref.kind === 'generated_visual' && now - rec.createdAtMs > GENERATED_CONTENT_TTL_MS) return true
601
+ if (rec.lifecycle === 'staged' && now - rec.createdAtMs > STAGED_TTL_MS) return true
602
+ if (rec.lifecycle === 'reserved' && now - (rec.reservedAtMs ?? rec.createdAtMs) > RESERVED_TTL_MS) return true
603
+ return false
604
+ }
605
+
606
+ /** Throwing resolver used by the query pipeline: id must be usable NOW. */
607
+ resolveUsable(id: string, clientQueueItemId?: string): { record: MediaRecord; path: string } {
608
+ const rec = this.getRecord(id)
609
+ if (!rec || rec.lifecycle === 'deleted') {
610
+ throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
611
+ }
612
+ if (rec.lifecycle === 'expired' || this.isContentExpired(rec)) {
613
+ throw new MediaStoreError('media_expired', `attachment ${id} expired`)
614
+ }
615
+ // An attachment id is never bearer authorization by itself: when a
616
+ // reservation exists, the caller must present the matching queue identity.
617
+ if (rec.lifecycle === 'reserved' && rec.clientQueueItemId &&
618
+ clientQueueItemId !== rec.clientQueueItemId) {
619
+ throw new MediaStoreError('media_conflict', `attachment ${id} is reserved by another queue item`)
620
+ }
621
+ const content = this.getContent(id, 'phone')
622
+ if (content.status !== 'ok') {
623
+ throw new MediaStoreError('media_unavailable', `attachment ${id} content unavailable`)
624
+ }
625
+ return { record: rec, path: content.path }
626
+ }
627
+
628
+ // ── Lifecycle transitions (idempotent, serialized) ────────────────────────
629
+
630
+ /** Reserve staged media for a queued prompt. Safe to replay. */
631
+ reserve(ids: string[], owner: { sessionId?: string; clientQueueItemId: string }): Promise<MediaAttachmentRef[]> {
632
+ return this.withLock(() => {
633
+ const now = Date.now()
634
+ // Validate all before mutating any — reservation is all-or-nothing.
635
+ for (const id of ids) {
636
+ const rec = this.records.get(id)
637
+ if (!rec || rec.lifecycle === 'deleted') throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
638
+ if (rec.lifecycle === 'expired' || this.isContentExpired(rec, now)) throw new MediaStoreError('media_expired', `attachment ${id} expired`)
639
+ if (rec.lifecycle === 'reserved' && rec.clientQueueItemId && rec.clientQueueItemId !== owner.clientQueueItemId) {
640
+ throw new MediaStoreError('media_conflict', `attachment ${id} already reserved by another queue item`)
641
+ }
642
+ // associated: replayed reserve after association is a no-op (associate wins).
643
+ }
644
+ const refs: MediaAttachmentRef[] = []
645
+ let dirty = false
646
+ for (const id of ids) {
647
+ const rec = this.records.get(id)!
648
+ refs.push(rec.ref)
649
+ if (rec.lifecycle === 'staged' ||
650
+ (rec.lifecycle === 'reserved' && rec.clientQueueItemId !== owner.clientQueueItemId)) {
651
+ rec.lifecycle = 'reserved'
652
+ rec.clientQueueItemId = owner.clientQueueItemId
653
+ if (owner.sessionId) rec.sessionId = owner.sessionId
654
+ rec.reservedAtMs = now
655
+ rec.updatedAtMs = now
656
+ dirty = true
657
+ } else if (rec.lifecycle === 'reserved' && owner.sessionId && rec.sessionId !== owner.sessionId) {
658
+ rec.sessionId = owner.sessionId
659
+ rec.updatedAtMs = now
660
+ dirty = true
661
+ }
662
+ }
663
+ if (dirty) this.saveIndex()
664
+ return refs
665
+ })
666
+ }
667
+
668
+ /** Bind media to its final run/message. Safe to replay; wins over a
669
+ * delayed release. */
670
+ associate(ids: string[], target: { sessionId?: string; runId?: string; globalMsgNum?: number }): Promise<void> {
671
+ return this.withLock(() => {
672
+ const now = Date.now()
673
+ let dirty = false
674
+ for (const id of ids) {
675
+ const rec = this.records.get(id)
676
+ if (!rec || rec.lifecycle === 'deleted') throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
677
+ if (rec.lifecycle === 'expired') throw new MediaStoreError('media_expired', `attachment ${id} expired`)
678
+ let recDirty = false
679
+ if (rec.lifecycle !== 'associated') {
680
+ rec.lifecycle = 'associated'
681
+ rec.associatedAtMs = now
682
+ recDirty = true
683
+ }
684
+ if (target.sessionId && rec.sessionId !== target.sessionId) { rec.sessionId = target.sessionId; recDirty = true }
685
+ if (target.runId && rec.runId !== target.runId) { rec.runId = target.runId; recDirty = true }
686
+ if (target.globalMsgNum != null && rec.globalMsgNum !== target.globalMsgNum) { rec.globalMsgNum = target.globalMsgNum; recDirty = true }
687
+ if (recDirty) {
688
+ rec.updatedAtMs = now
689
+ dirty = true
690
+ }
691
+ }
692
+ if (dirty) this.saveIndex()
693
+ })
694
+ }
695
+
696
+ /** Release staged/reserved media owned by the supplied queue identity
697
+ * (user cancelled before the run). Associated media is untouched —
698
+ * associate wins over a delayed release. Safe to replay. */
699
+ release(ids: string[], owner: { sessionId?: string; clientQueueItemId?: string }): Promise<void> {
700
+ return this.withLock(() => {
701
+ const now = Date.now()
702
+ let dirty = false
703
+ for (const id of ids) {
704
+ const rec = this.records.get(id)
705
+ if (!rec || rec.lifecycle === 'deleted' || rec.lifecycle === 'expired' || rec.lifecycle === 'associated') continue
706
+ if (rec.lifecycle === 'reserved' &&
707
+ rec.clientQueueItemId && owner.clientQueueItemId !== rec.clientQueueItemId) {
708
+ continue // not this caller's reservation
709
+ }
710
+ this.removeAssetFiles(rec)
711
+ rec.lifecycle = 'expired'
712
+ rec.updatedAtMs = now
713
+ dirty = true
714
+ }
715
+ if (dirty) this.saveIndex()
716
+ })
717
+ }
718
+
719
+ /** Hard delete — permitted only while unassociated (staged/reserved).
720
+ * Associated records follow conversation retention instead. */
721
+ deleteUnassociated(id: string): Promise<boolean> {
722
+ return this.withLock(() => {
723
+ const rec = this.records.get(id)
724
+ if (!rec || rec.lifecycle === 'deleted') return false
725
+ if (rec.lifecycle === 'associated') {
726
+ throw new MediaStoreError('media_conflict', 'associated media follows message retention')
727
+ }
728
+ this.removeAssetFiles(rec)
729
+ rec.lifecycle = 'deleted'
730
+ rec.contentRemoved = true
731
+ rec.updatedAtMs = Date.now()
732
+ this.saveIndex()
733
+ return true
734
+ })
735
+ }
736
+
737
+ private removeAssetFiles(rec: MediaRecord): void {
738
+ try {
739
+ rmSync(join(this.root, 'assets', rec.ref.id), { recursive: true, force: true })
740
+ } catch { /* best effort */ }
741
+ rec.contentRemoved = true
742
+ }
743
+
744
+ // ── Garbage collection ─────────────────────────────────────────────────────
745
+
746
+ runGC(now = Date.now()): Promise<{ expired: number; contentDropped: number; pruned: number }> {
747
+ return this.withLock(() => {
748
+ let expired = 0
749
+ let contentDropped = 0
750
+ let pruned = 0
751
+ for (const [id, rec] of this.records) {
752
+ try {
753
+ // Traffic frame content TTL — metadata survives, bytes go.
754
+ if (rec.ref.kind === 'traffic_frame' && !rec.contentRemoved &&
755
+ now - rec.createdAtMs > TRAFFIC_CONTENT_TTL_MS) {
756
+ this.removeAssetFiles(rec)
757
+ rec.updatedAtMs = now
758
+ contentDropped++
759
+ }
760
+ // Agent-selected output images are a bounded local lens cache, not
761
+ // a second permanent copy of generated/research/email artifacts.
762
+ // Their generic metadata ref survives for honest "expired" UI.
763
+ if (rec.ref.kind === 'generated_visual' && !rec.contentRemoved &&
764
+ now - rec.createdAtMs > GENERATED_CONTENT_TTL_MS) {
765
+ this.removeAssetFiles(rec)
766
+ rec.updatedAtMs = now
767
+ contentDropped++
768
+ }
769
+ // Staged / reserved lifecycle expiry.
770
+ if ((rec.lifecycle === 'staged' && now - rec.createdAtMs > STAGED_TTL_MS) ||
771
+ (rec.lifecycle === 'reserved' && now - (rec.reservedAtMs ?? rec.createdAtMs) > RESERVED_TTL_MS)) {
772
+ this.removeAssetFiles(rec)
773
+ rec.lifecycle = 'expired'
774
+ rec.updatedAtMs = now
775
+ expired++
776
+ }
777
+ // Tombstone pruning — expired/deleted rows that nothing references.
778
+ if ((rec.lifecycle === 'expired' || rec.lifecycle === 'deleted') &&
779
+ rec.globalMsgNum == null &&
780
+ now - rec.updatedAtMs > TOMBSTONE_TTL_MS) {
781
+ this.records.delete(id)
782
+ pruned++
783
+ }
784
+ } catch (err) {
785
+ console.error(`[media-store] GC error for ${id}:`, err)
786
+ }
787
+ }
788
+ if (expired || contentDropped || pruned) this.saveIndex()
789
+ return { expired, contentDropped, pruned }
790
+ })
791
+ }
792
+
793
+ startGC(): void {
794
+ if (this.gcTimer) return
795
+ void this.runGC().catch((err) => console.error('[media-store] boot GC failed:', err))
796
+ this.gcTimer = setInterval(() => {
797
+ void this.runGC().catch((err) => console.error('[media-store] GC failed:', err))
798
+ }, MEDIA_GC_INTERVAL_MS)
799
+ this.gcTimer.unref?.()
800
+ }
801
+
802
+ stopGC(): void {
803
+ if (this.gcTimer) clearInterval(this.gcTimer)
804
+ this.gcTimer = null
805
+ }
806
+
807
+ stats(): { total: number; byLifecycle: Record<string, number> } {
808
+ const byLifecycle: Record<string, number> = {}
809
+ for (const rec of this.records.values()) {
810
+ byLifecycle[rec.lifecycle] = (byLifecycle[rec.lifecycle] ?? 0) + 1
811
+ }
812
+ return { total: this.records.size, byLifecycle }
813
+ }
814
+ }
815
+
816
+ // ── Default singleton ────────────────────────────────────────────────────────
817
+
818
+ let defaultStore: MediaStore | null = null
819
+
820
+ export function getMediaStore(): MediaStore {
821
+ if (!defaultStore) defaultStore = new MediaStore()
822
+ return defaultStore
823
+ }
824
+
825
+ /** Test hook — point the singleton at a temp-dir store so route tests never
826
+ * touch server/data/media. Returns the previous store for restoration. */
827
+ export function _setMediaStoreForTests(store: MediaStore | null): MediaStore | null {
828
+ const prev = defaultStore
829
+ defaultStore = store
830
+ return prev
831
+ }
832
+
833
+ export { ImageSafetyError }