@gotcos/glasses-server 6.3.1 → 6.5.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 +18 -7
- package/CHANGELOG.md +83 -0
- package/README.md +24 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +57 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- 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 }
|