@gotcos/glasses-server 6.27.2 → 6.27.3

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 CHANGED
@@ -23,6 +23,13 @@ BIND_HOST=0.0.0.0
23
23
  # ~/.cos-glasses/data/media alongside the standalone conversation/archive data.
24
24
  # COS_MEDIA_ROOT=/path/on-a-local-volume/media
25
25
 
26
+ # Private canary for server 6.27.3 + companion 6.8.343. When enabled, every
27
+ # MP4/MOV uses a durable, resumable 256 KiB upload draft instead of one long
28
+ # WebView request. Accepted chunks and finalize receipts survive server
29
+ # restarts. Leave off for the released transport; COS Control 0.5.20 manages
30
+ # this setting and prevents unsafe binary rollback while V2 state exists.
31
+ # COS_VIDEO_UPLOAD_V2=0
32
+
26
33
  # Optional logical server identity location. Most installs should keep the
27
34
  # default (~/.cos-glasses/server-instance-id) so reconnects can verify the same
28
35
  # server after Wi-Fi/Tailscale changes and process restarts.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 6.27.3
2
+
3
+ ### Durable, resumable video transport (private canary)
4
+
5
+ - Replaces fragile single-request phone video uploads with a generation-pinned,
6
+ resumable 256 KiB protocol whose accepted chunks survive server restarts.
7
+ - Adds idempotent init/finalize receipts, explicit acknowledgement and cancel,
8
+ bounded draft retention, and maintenance status so updates cannot erase a
9
+ video draft that is still being transferred or whose receipt is not stored.
10
+ - Keeps the published media record byte-compatible with 6.27.2: the original
11
+ MP4/MOV remains required and the existing validated ffprobe/ffmpeg path stays
12
+ the fallback. Phone frame extraction is separately gated until its physical
13
+ iPhone decoder/canvas acceptance test executes.
14
+ - New V2 admission is disabled by default (`COS_VIDEO_UPLOAD_V2=0`). Existing
15
+ V2 drafts remain observable, cancellable, and finalizable after the flag is
16
+ turned off so rollback never strands accepted bytes.
17
+
1
18
  ## 6.27.2
2
19
 
3
20
  ### One finalizer for phone and Mac dictation
package/README.md CHANGED
@@ -176,8 +176,12 @@ in the managed CLI working directory),
176
176
  `COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
177
177
  `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
178
178
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
179
- machine-wide rollback for build 204+ server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
180
- location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
179
+ machine-wide rollback for build 204+ server-owned query recovery),
180
+ `COS_MEDIA_ROOT` (optional image/video store location; default
181
+ `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
182
+ resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
183
+ accepted 256 KiB chunks and finalize receipts across restarts; keep it off when
184
+ using an older companion. Your name + transcription vocabulary live in
181
185
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
182
186
  Factory example values are ignored; add the real names, companies, acronyms,
183
187
  and specialist terms you say often. Guided Setup writes a safe empty profile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.2",
3
+ "version": "6.27.3",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -162,6 +162,20 @@ app.use('/api', requireApiToken(API_TOKEN))
162
162
  // through their true terminal boundary.
163
163
  app.use('/api', (req, res, next) => {
164
164
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
165
+ // V2 video chunks are bounded to 256 KiB and commit through the upload
166
+ // registry's own generation lock. Holding the global mutation lease while
167
+ // the phone transfers the body recreated the exact 90-second drain failure
168
+ // this protocol exists to remove. Admission is checked by the route before
169
+ // bytes are committed; in-flight bounded bodies finish or are discarded.
170
+ const videoUploadBody = req.method === 'PUT'
171
+ && /^\/media\/video-upload\/vu_[0-9a-f]{24}\/(?:original|frame)\/\d+$/.test(req.path)
172
+ // Finalize may run ffmpeg for a long clip. Its durable registry state is the
173
+ // restart/rollback gate, so holding the global request lease as well would
174
+ // recreate Control's 90-second drain timeout. The route performs its own
175
+ // fail-closed admission immediately before claiming `finalizing`.
176
+ const videoUploadFinalize = req.method === 'POST'
177
+ && /^\/media\/video-upload\/vu_[0-9a-f]{24}\/finalize$/.test(req.path)
178
+ if (videoUploadBody || videoUploadFinalize) return next()
165
179
  const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
166
180
  || req.path === '/query'
167
181
  || req.path === '/diagnostics/provider-proof'
@@ -190,6 +204,14 @@ app.use('/api', (req, res, next) => {
190
204
  nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
191
205
  ? req.headers['x-cos-maintenance-nonce'] : undefined,
192
206
  })
207
+ // Cleanup and receipt acknowledgement only reduce rollback blockers. They
208
+ // remain available during a drain so Control is never forced to wait for a
209
+ // four-hour upload TTL after the phone already cancelled or persisted the
210
+ // terminal receipt.
211
+ const videoUploadCleanup = /^\/media\/video-upload\/vu_[0-9a-f]{24}$/.test(req.path)
212
+ && req.method === 'DELETE'
213
+ || /^\/media\/video-upload\/vu_[0-9a-f]{24}\/ack$/.test(req.path)
214
+ && req.method === 'POST'
193
215
 
194
216
  try {
195
217
  // KNOWN HAZARD, deliberately not fixed here (2026-08-11). This lease is held for
@@ -207,7 +229,7 @@ app.use('/api', (req, res, next) => {
207
229
  // of the network transfer, which means changing a fail-closed middleware that
208
230
  // guards every mutation. That needs its own pass and its own tests.
209
231
  const lease = acquireMaintenanceWork('api_mutation', {
210
- allowDuringDrain: controllerProof,
232
+ allowDuringDrain: controllerProof || videoUploadCleanup,
211
233
  })
212
234
  let released = false
213
235
  const release = () => {
@@ -31,7 +31,7 @@ import {
31
31
  writeFileSync,
32
32
  } from 'node:fs'
33
33
  import { join, resolve, sep } from 'node:path'
34
- import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
34
+ import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
35
35
  import { dataPath } from './data-dir.js'
36
36
  import {
37
37
  isValidMediaId,
@@ -183,6 +183,10 @@ export interface MediaRecord {
183
183
  updatedAtMs: number
184
184
  reservedAtMs?: number
185
185
  associatedAtMs?: number
186
+ /** Durable provenance for resumable video uploads. It is never exposed in
187
+ * the public attachment ref; it lets a lost finalize response or server
188
+ * restart rediscover the one record that upload already published. */
189
+ videoUploadId?: string
186
190
  }
187
191
 
188
192
  interface MediaIndexFile {
@@ -236,6 +240,10 @@ export interface IngestRichMediaFileInput {
236
240
  * existing ceiling; chunked finalize passes 'chunked' so a multi-hundred-MB video
237
241
  * is judged against the chunked cap instead of being refused AFTER transfer. */
238
242
  transfer?: MediaTransferMode
243
+ /** Preallocated by the durable video-upload manifest before publication. */
244
+ mediaId?: string
245
+ /** Private idempotency link used only by video-upload receipt recovery. */
246
+ videoUploadId?: string
239
247
  }
240
248
 
241
249
  /** Handle for a streaming upload's staging file. `dispose()` is idempotent and
@@ -313,6 +321,9 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
313
321
  updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
314
322
  ...(typeof r.reservedAtMs === 'number' ? { reservedAtMs: r.reservedAtMs } : {}),
315
323
  ...(typeof r.associatedAtMs === 'number' ? { associatedAtMs: r.associatedAtMs } : {}),
324
+ ...(typeof r.videoUploadId === 'string' && /^vu_[0-9a-f]{24}$/.test(r.videoUploadId)
325
+ ? { videoUploadId: r.videoUploadId }
326
+ : {}),
316
327
  }
317
328
  }
318
329
 
@@ -468,15 +479,35 @@ export class MediaStore {
468
479
  rmSync(join(this.root, 'tmp'), { recursive: true, force: true })
469
480
  mkdirSync(join(this.root, 'tmp'), { recursive: true, mode: 0o700 })
470
481
  } catch { /* best effort */ }
482
+ let dirty = false
471
483
  if (this.allowOrphanCleanup) {
472
484
  try {
473
485
  for (const entry of readdirSync(join(this.root, 'assets'))) {
474
486
  try {
475
487
  if (!this.records.has(entry)) {
476
- // Unpublished orphan the upload died between rename and index
477
- // publish. Remove; the client never received this id.
478
- rmSync(join(this.root, 'assets', entry), { recursive: true, force: true })
479
- console.warn(`[media-store] removed unpublished orphan asset ${entry}`)
488
+ const intentPath = join(this.root, 'assets', entry, 'publication-intent.json')
489
+ let recovered = false
490
+ if (existsSync(intentPath)) {
491
+ try {
492
+ const candidate = sanitizeRecord(JSON.parse(readFileSync(intentPath, 'utf8')))
493
+ if (candidate && candidate.ref.id === entry && candidate.videoUploadId
494
+ && existsSync(this.absPath(candidate.storagePath))) {
495
+ this.records.set(entry, candidate)
496
+ dirty = true
497
+ recovered = true
498
+ console.warn(`[media-store] recovered resumable video publication ${entry}`)
499
+ }
500
+ } catch {
501
+ // An unreadable/inconsistent intent grants no publication.
502
+ // The client can safely repeat finalize from its durable draft.
503
+ }
504
+ }
505
+ if (!recovered) {
506
+ // Unpublished orphan — the upload died between rename and index
507
+ // publish and supplied no valid durable publication intent.
508
+ rmSync(join(this.root, 'assets', entry), { recursive: true, force: true })
509
+ console.warn(`[media-store] removed unpublished orphan asset ${entry}`)
510
+ }
480
511
  }
481
512
  } catch { /* skip this asset */ }
482
513
  }
@@ -484,7 +515,6 @@ export class MediaStore {
484
515
  } else {
485
516
  console.warn('[media-store] preserving unindexed asset dirs for recovery this boot')
486
517
  }
487
- let dirty = false
488
518
  for (const rec of this.records.values()) {
489
519
  try {
490
520
  if (!rec.contentRemoved && rec.lifecycle !== 'deleted' && rec.lifecycle !== 'expired' &&
@@ -497,7 +527,13 @@ export class MediaStore {
497
527
  } catch { /* skip */ }
498
528
  }
499
529
  if (dirty) {
500
- try { this.saveIndex() } catch (err) { console.error('[media-store] reconcile save failed:', err) }
530
+ try {
531
+ this.saveIndex()
532
+ for (const rec of this.records.values()) {
533
+ if (!rec.videoUploadId) continue
534
+ try { rmSync(join(this.root, 'assets', rec.ref.id, 'publication-intent.json'), { force: true }) } catch { /* next boot */ }
535
+ }
536
+ } catch (err) { console.error('[media-store] reconcile save failed:', err) }
501
537
  }
502
538
  }
503
539
 
@@ -563,10 +599,23 @@ export class MediaStore {
563
599
  }
564
600
 
565
601
  private async publishPreparedRichMedia(
566
- input: { label?: string; capturedAt?: string; sessionId?: string },
602
+ input: {
603
+ label?: string
604
+ capturedAt?: string
605
+ sessionId?: string
606
+ mediaId?: string
607
+ videoUploadId?: string
608
+ },
567
609
  prepared: PreparedRichMediaFile,
568
610
  ): Promise<MediaAttachmentRef> {
569
- const id = `m_${randomBytes(12).toString('hex')}`
611
+ const id = input.mediaId && isValidMediaId(input.mediaId)
612
+ ? input.mediaId
613
+ : `m_${randomBytes(12).toString('hex')}`
614
+ const existing = this.getRecord(id)
615
+ if (existing) {
616
+ if (input.videoUploadId && existing.videoUploadId === input.videoUploadId) return existing.ref
617
+ throw new MediaStoreError('media_conflict', `attachment ${id} already exists`)
618
+ }
570
619
  const now = Date.now()
571
620
  const nowIso = new Date(now).toISOString()
572
621
  const extension = prepared.category === 'video'
@@ -631,13 +680,24 @@ export class MediaStore {
631
680
  sha256,
632
681
  lifecycle: 'staged',
633
682
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
683
+ ...(input.videoUploadId ? { videoUploadId: input.videoUploadId } : {}),
634
684
  createdAtMs: now,
635
685
  updatedAtMs: now,
636
686
  }
687
+ // A crash can land after assets/<id> is renamed but before index.json is
688
+ // committed. Keep a private complete record beside the asset so boot
689
+ // reconciliation can finish that exact publication rather than deleting a
690
+ // valid video as an orphan. The public ref never exposes this file.
691
+ durableAtomicWriteFileSync(
692
+ join(this.root, 'assets', id, 'publication-intent.json'),
693
+ JSON.stringify(record),
694
+ { mode: 0o600 },
695
+ )
637
696
  await this.withLock(() => {
638
697
  this.records.set(id, record)
639
698
  this.saveIndex()
640
699
  })
700
+ try { rmSync(join(this.root, 'assets', id, 'publication-intent.json'), { force: true }) } catch { /* boot can clean it */ }
641
701
  // AFTER publication and outside the lock: x265 encodes at roughly real
642
702
  // time, so awaiting it here would hold the upload response open for the
643
703
  // length of the video.
@@ -814,6 +874,16 @@ export class MediaStore {
814
874
  return this.getRecord(id)?.ref ?? null
815
875
  }
816
876
 
877
+ /** Private receipt-recovery lookup. Upload ids are not capabilities and are
878
+ * never returned by ordinary media metadata routes. */
879
+ findByVideoUploadId(uploadId: string): MediaRecord | null {
880
+ if (!/^vu_[0-9a-f]{24}$/.test(uploadId)) return null
881
+ for (const record of this.records.values()) {
882
+ if (record.videoUploadId === uploadId) return record
883
+ }
884
+ return null
885
+ }
886
+
817
887
  /** Recover the public refs associated with one exact conversation turn.
818
888
  *
819
889
  * The media index is already the durable lifecycle authority for uploaded
@@ -1159,6 +1229,27 @@ export class MediaStore {
1159
1229
  })
1160
1230
  }
1161
1231
 
1232
+ /** Resumable-upload cancel may release a just-published result, but it must
1233
+ * never delete a reservation that a queued Ask already owns. */
1234
+ deleteExactlyStaged(id: string): Promise<'deleted' | 'retained' | 'missing'> {
1235
+ return this.withLock(() => {
1236
+ const rec = this.records.get(id)
1237
+ if (!rec || rec.lifecycle === 'deleted') return 'missing'
1238
+ if (rec.lifecycle !== 'staged') return 'retained'
1239
+ this.removeAssetFiles(rec)
1240
+ rec.lifecycle = 'deleted'
1241
+ rec.contentRemoved = true
1242
+ rec.updatedAtMs = Date.now()
1243
+ this.saveIndex()
1244
+ return 'deleted'
1245
+ })
1246
+ }
1247
+
1248
+ /** Durable upload manifests live beside, not inside, the legacy tmp tree. */
1249
+ rootDirectory(): string {
1250
+ return this.root
1251
+ }
1252
+
1162
1253
  private removeAssetFiles(rec: MediaRecord): void {
1163
1254
  try {
1164
1255
  rmSync(join(this.root, 'assets', rec.ref.id), { recursive: true, force: true })
@@ -50,7 +50,7 @@ export interface ResolvedQueryAttachments {
50
50
  * an optional aid over canonical text — so this ceiling cannot be treated as a
51
51
  * budget to trim silently. It has to fit a whole video.
52
52
  */
53
- const MAX_MODEL_IMAGE_INPUTS = VIDEO_SUMMARY_FRAMES_MAX
53
+ export const MAX_MODEL_IMAGE_INPUTS = VIDEO_SUMMARY_FRAMES_MAX
54
54
  const MAX_ATTACHMENT_PROMPT_CHARS = 60_000
55
55
 
56
56
  export class QueryAttachmentError extends Error {
@@ -0,0 +1,726 @@
1
+ // Durable resumable video upload protocol.
2
+ //
3
+ // Unlike the legacy generic upload registry, this state lives outside media/tmp,
4
+ // survives a server restart, binds init to an idempotency key + server identity,
5
+ // and retains a terminal publication receipt until the phone acknowledges it.
6
+
7
+ import { createHash, randomBytes } from 'node:crypto'
8
+ import {
9
+ closeSync,
10
+ constants,
11
+ existsSync,
12
+ fsyncSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ rmSync,
18
+ statSync,
19
+ statfsSync,
20
+ writeFileSync,
21
+ writeSync,
22
+ } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import type { MediaAttachmentRef } from '../../shared/media-attachment.js'
25
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
26
+ import { getMediaStore } from './media-store.js'
27
+ import { MAX_CHUNKED_MEDIA_BYTES, MAX_VIDEO_DURATION_MS } from './rich-media-safety.js'
28
+
29
+ export const VIDEO_UPLOAD_V2_PROTOCOL = 1
30
+ export const VIDEO_UPLOAD_V2_CHUNK_BYTES = 256 * 1024
31
+ export const VIDEO_UPLOAD_V2_MAX_FRAME_BYTES = 256 * 1024
32
+ export const VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES = 2 * 1024 * 1024
33
+ export const VIDEO_UPLOAD_PHONE_FRAMES_MIN = 8
34
+ export const VIDEO_UPLOAD_PHONE_FRAMES_MAX = 12
35
+ export const VIDEO_UPLOAD_SERVER_FRAMES_MIN = 8
36
+ export const VIDEO_UPLOAD_SERVER_FRAMES_MAX = 16
37
+ export const VIDEO_UPLOAD_V2_TTL_MS = 4 * 60 * 60_000
38
+ export const VIDEO_UPLOAD_V2_RECEIPT_TTL_MS = 24 * 60 * 60_000
39
+ export const VIDEO_UPLOAD_V2_MAX_CONCURRENT = 8
40
+ export const VIDEO_UPLOAD_V2_TOTAL_RESERVED_BYTES = 4 * 1024 * 1024 * 1024
41
+ export const VIDEO_UPLOAD_V2_FREE_DISK_RESERVE_BYTES = 512 * 1024 * 1024
42
+ export const VIDEO_UPLOAD_V2_ACCEPTED_MIMES = ['video/mp4', 'video/quicktime'] as const
43
+
44
+ const UPLOAD_ID_RE = /^vu_[0-9a-f]{24}$/
45
+ const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
46
+ const MEDIA_ID_RE = /^m_[0-9a-f]{24}$/
47
+
48
+ export function videoUploadV2Enabled(): boolean {
49
+ return process.env.COS_VIDEO_UPLOAD_V2 === '1'
50
+ }
51
+
52
+ export function phoneVideoFramesEnabled(): boolean {
53
+ return videoUploadV2Enabled() && process.env.COS_VIDEO_PHONE_FRAMES === '1'
54
+ }
55
+
56
+ export function isValidVideoUploadId(value: unknown): value is string {
57
+ return typeof value === 'string' && UPLOAD_ID_RE.test(value)
58
+ }
59
+
60
+ export type VideoUploadState = 'receiving' | 'finalizing' | 'published' | 'cancelled' | 'failed'
61
+
62
+ interface AcceptedPart {
63
+ bytes: number
64
+ sha256: string
65
+ }
66
+
67
+ export interface VideoUploadManifest {
68
+ v: 1
69
+ uploadId: string
70
+ clientRequestId: string
71
+ serverInstanceId: string
72
+ state: VideoUploadState
73
+ generation: number
74
+ totalBytes: number
75
+ chunkBytes: number
76
+ chunkCount: number
77
+ mime: typeof VIDEO_UPLOAD_V2_ACCEPTED_MIMES[number]
78
+ label?: string
79
+ capturedAt?: string
80
+ sessionId?: string
81
+ original: Record<string, AcceptedPart>
82
+ frames: Record<string, AcceptedPart>
83
+ mediaId?: string
84
+ receipt?: MediaAttachmentRef
85
+ acknowledged?: boolean
86
+ failure?: string
87
+ createdAtMs: number
88
+ updatedAtMs: number
89
+ expiresAtMs: number
90
+ }
91
+
92
+ export interface VideoUploadInitInput {
93
+ clientRequestId: unknown
94
+ serverInstanceId: unknown
95
+ totalBytes: unknown
96
+ mime: unknown
97
+ label?: unknown
98
+ capturedAt?: unknown
99
+ sessionId?: unknown
100
+ }
101
+
102
+ export type VideoUploadErrorCode =
103
+ | 'video_upload_disabled'
104
+ | 'video_upload_not_found'
105
+ | 'video_upload_conflict'
106
+ | 'video_upload_busy'
107
+ | 'video_upload_incomplete'
108
+ | 'video_upload_invalid'
109
+ | 'video_upload_quota'
110
+ | 'video_upload_cancelled'
111
+ | 'video_upload_failed'
112
+ | 'server_identity_mismatch'
113
+
114
+ export class VideoUploadError extends Error {
115
+ constructor(
116
+ readonly code: VideoUploadErrorCode,
117
+ message: string,
118
+ readonly detail: Readonly<Record<string, unknown>> = {},
119
+ ) {
120
+ super(message)
121
+ this.name = 'VideoUploadError'
122
+ }
123
+ }
124
+
125
+ export interface VideoUploadProgress {
126
+ protocol: 1
127
+ uploadId: string
128
+ serverInstanceId: string
129
+ state: VideoUploadState
130
+ totalBytes: number
131
+ chunkBytes: number
132
+ chunkCount: number
133
+ receivedOriginalChunks: number[]
134
+ missingOriginalChunks: number[]
135
+ receivedFrames: number[]
136
+ expiresAt: string
137
+ acknowledged: boolean
138
+ attachment?: MediaAttachmentRef
139
+ failure?: string
140
+ }
141
+
142
+ export interface VideoUploadStatus {
143
+ protocol: 1
144
+ enabled: boolean
145
+ receiving: number
146
+ finalizing: number
147
+ unacknowledgedPublished: number
148
+ failed: number
149
+ blocksRestart: boolean
150
+ blocksRollback: boolean
151
+ }
152
+
153
+ export interface VideoUploadRegistryOptions {
154
+ root?: string
155
+ now?: () => number
156
+ maxConcurrent?: number
157
+ maxReservedBytes?: number
158
+ freeDiskReserveBytes?: number
159
+ }
160
+
161
+ function boundedString(value: unknown, max: number): string | undefined {
162
+ return typeof value === 'string' && value.trim()
163
+ ? value.trim().replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max)
164
+ : undefined
165
+ }
166
+
167
+ function sha256(bytes: Buffer): string {
168
+ return createHash('sha256').update(bytes).digest('hex')
169
+ }
170
+
171
+ function positiveSafeInteger(value: unknown): value is number {
172
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
173
+ }
174
+
175
+ function parseIndex(value: unknown): number | null {
176
+ const raw = typeof value === 'string' && /^\d{1,9}$/.test(value) ? Number(value) : value
177
+ return typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0 ? raw : null
178
+ }
179
+
180
+ function sameInit(manifest: VideoUploadManifest, input: Required<Pick<VideoUploadManifest,
181
+ 'serverInstanceId' | 'totalBytes' | 'mime'>> & Pick<VideoUploadManifest, 'label' | 'capturedAt' | 'sessionId'>): boolean {
182
+ return manifest.serverInstanceId === input.serverInstanceId
183
+ && manifest.totalBytes === input.totalBytes
184
+ && manifest.mime === input.mime
185
+ && (manifest.label ?? '') === (input.label ?? '')
186
+ && (manifest.capturedAt ?? '') === (input.capturedAt ?? '')
187
+ && (manifest.sessionId ?? '') === (input.sessionId ?? '')
188
+ }
189
+
190
+ function parseManifest(raw: unknown): VideoUploadManifest | null {
191
+ if (!raw || typeof raw !== 'object') return null
192
+ const r = raw as Record<string, unknown>
193
+ if (r.v !== 1 || !isValidVideoUploadId(r.uploadId)
194
+ || typeof r.clientRequestId !== 'string' || !CLIENT_REQUEST_RE.test(r.clientRequestId)
195
+ || typeof r.serverInstanceId !== 'string' || r.serverInstanceId.length < 8
196
+ || !positiveSafeInteger(r.totalBytes) || r.totalBytes > MAX_CHUNKED_MEDIA_BYTES
197
+ || !positiveSafeInteger(r.chunkBytes) || !positiveSafeInteger(r.chunkCount)
198
+ || !VIDEO_UPLOAD_V2_ACCEPTED_MIMES.includes(r.mime as typeof VIDEO_UPLOAD_V2_ACCEPTED_MIMES[number])) return null
199
+ const state = r.state
200
+ if (state !== 'receiving' && state !== 'finalizing' && state !== 'published'
201
+ && state !== 'cancelled' && state !== 'failed') return null
202
+ const parts = (value: unknown): Record<string, AcceptedPart> => {
203
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
204
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => {
205
+ if (!/^\d{1,9}$/.test(key) || !item || typeof item !== 'object') return []
206
+ const p = item as Record<string, unknown>
207
+ if (!positiveSafeInteger(p.bytes) || typeof p.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(p.sha256)) return []
208
+ return [[key, { bytes: p.bytes, sha256: p.sha256 }]]
209
+ }))
210
+ }
211
+ return {
212
+ v: 1,
213
+ uploadId: r.uploadId,
214
+ clientRequestId: r.clientRequestId,
215
+ serverInstanceId: r.serverInstanceId,
216
+ state,
217
+ generation: typeof r.generation === 'number' && Number.isSafeInteger(r.generation) ? r.generation : 0,
218
+ totalBytes: r.totalBytes,
219
+ chunkBytes: r.chunkBytes,
220
+ chunkCount: r.chunkCount,
221
+ mime: r.mime as VideoUploadManifest['mime'],
222
+ ...(boundedString(r.label, 120) ? { label: boundedString(r.label, 120) } : {}),
223
+ ...(boundedString(r.capturedAt, 40) ? { capturedAt: boundedString(r.capturedAt, 40) } : {}),
224
+ ...(boundedString(r.sessionId, 64) ? { sessionId: boundedString(r.sessionId, 64) } : {}),
225
+ original: parts(r.original),
226
+ frames: parts(r.frames),
227
+ ...(typeof r.mediaId === 'string' && MEDIA_ID_RE.test(r.mediaId) ? { mediaId: r.mediaId } : {}),
228
+ ...(r.receipt && typeof r.receipt === 'object' ? { receipt: r.receipt as MediaAttachmentRef } : {}),
229
+ acknowledged: r.acknowledged === true,
230
+ ...(boundedString(r.failure, 160) ? { failure: boundedString(r.failure, 160) } : {}),
231
+ createdAtMs: typeof r.createdAtMs === 'number' ? r.createdAtMs : Date.now(),
232
+ updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
233
+ expiresAtMs: typeof r.expiresAtMs === 'number' ? r.expiresAtMs : Date.now(),
234
+ }
235
+ }
236
+
237
+ export class VideoUploadRegistry {
238
+ private readonly root: string
239
+ private readonly now: () => number
240
+ private readonly maxConcurrent: number
241
+ private readonly maxReservedBytes: number
242
+ private readonly freeDiskReserveBytes: number
243
+ private readonly manifests = new Map<string, VideoUploadManifest>()
244
+ private readonly byClientRequest = new Map<string, string>()
245
+ private readonly locks = new Map<string, Promise<unknown>>()
246
+ private readonly finalizers = new Map<string, Promise<VideoUploadProgress>>()
247
+ private readonly activeWriters = new Map<string, number>()
248
+
249
+ constructor(options: VideoUploadRegistryOptions = {}) {
250
+ this.root = options.root ?? join(getMediaStore().rootDirectory(), 'video-upload-v1')
251
+ this.now = options.now ?? Date.now
252
+ this.maxConcurrent = options.maxConcurrent ?? VIDEO_UPLOAD_V2_MAX_CONCURRENT
253
+ this.maxReservedBytes = options.maxReservedBytes ?? VIDEO_UPLOAD_V2_TOTAL_RESERVED_BYTES
254
+ this.freeDiskReserveBytes = options.freeDiskReserveBytes ?? VIDEO_UPLOAD_V2_FREE_DISK_RESERVE_BYTES
255
+ mkdirSync(this.root, { recursive: true, mode: 0o700 })
256
+ this.load()
257
+ this.reconcilePublished()
258
+ this.sweepExpired()
259
+ }
260
+
261
+ init(input: VideoUploadInitInput): VideoUploadProgress {
262
+ if (!videoUploadV2Enabled()) throw new VideoUploadError('video_upload_disabled', 'resumable video upload is disabled')
263
+ const clientRequestId = boundedString(input.clientRequestId, 160)
264
+ const serverInstanceId = boundedString(input.serverInstanceId, 160)
265
+ if (!clientRequestId || !CLIENT_REQUEST_RE.test(clientRequestId) || !serverInstanceId) {
266
+ throw new VideoUploadError('video_upload_invalid', 'valid clientRequestId and serverInstanceId are required')
267
+ }
268
+ if (!positiveSafeInteger(input.totalBytes) || input.totalBytes > MAX_CHUNKED_MEDIA_BYTES) {
269
+ throw new VideoUploadError('video_upload_invalid', 'video size is invalid', { maxBytes: MAX_CHUNKED_MEDIA_BYTES })
270
+ }
271
+ if (!VIDEO_UPLOAD_V2_ACCEPTED_MIMES.includes(input.mime as VideoUploadManifest['mime'])) {
272
+ throw new VideoUploadError('video_upload_invalid', 'only MP4 and MOV videos are accepted')
273
+ }
274
+ const normalized = {
275
+ serverInstanceId,
276
+ totalBytes: input.totalBytes,
277
+ mime: input.mime as VideoUploadManifest['mime'],
278
+ label: boundedString(input.label, 120),
279
+ capturedAt: boundedString(input.capturedAt, 40),
280
+ sessionId: boundedString(input.sessionId, 64),
281
+ }
282
+ const existingId = this.byClientRequest.get(clientRequestId)
283
+ if (existingId) {
284
+ const existing = this.manifests.get(existingId)
285
+ if (existing && sameInit(existing, normalized)) return this.progress(existing)
286
+ throw new VideoUploadError('video_upload_conflict', 'clientRequestId was already used for different video metadata')
287
+ }
288
+ this.sweepExpired()
289
+ const active = [...this.manifests.values()].filter(item => item.state === 'receiving' || item.state === 'finalizing')
290
+ if (active.length >= this.maxConcurrent) throw new VideoUploadError('video_upload_quota', 'too many video uploads are active')
291
+ const reserved = active.reduce((sum, item) => sum + item.totalBytes, 0)
292
+ if (reserved + normalized.totalBytes > this.maxReservedBytes) {
293
+ throw new VideoUploadError('video_upload_quota', 'video upload disk quota is full')
294
+ }
295
+ try {
296
+ const fs = statfsSync(this.root)
297
+ const free = Number(fs.bavail) * Number(fs.bsize)
298
+ if (free - normalized.totalBytes < this.freeDiskReserveBytes) {
299
+ throw new VideoUploadError('video_upload_quota', 'not enough free disk for video upload')
300
+ }
301
+ } catch (error) {
302
+ if (error instanceof VideoUploadError) throw error
303
+ throw new VideoUploadError('video_upload_quota', 'free disk could not be verified')
304
+ }
305
+ const now = this.now()
306
+ const uploadId = `vu_${randomBytes(12).toString('hex')}`
307
+ const chunkCount = Math.ceil(normalized.totalBytes / VIDEO_UPLOAD_V2_CHUNK_BYTES)
308
+ const manifest: VideoUploadManifest = {
309
+ v: 1,
310
+ uploadId,
311
+ clientRequestId,
312
+ serverInstanceId,
313
+ state: 'receiving',
314
+ generation: 1,
315
+ totalBytes: normalized.totalBytes,
316
+ chunkBytes: VIDEO_UPLOAD_V2_CHUNK_BYTES,
317
+ chunkCount,
318
+ mime: normalized.mime,
319
+ ...(normalized.label ? { label: normalized.label } : {}),
320
+ ...(normalized.capturedAt ? { capturedAt: normalized.capturedAt } : {}),
321
+ ...(normalized.sessionId ? { sessionId: normalized.sessionId } : {}),
322
+ original: {},
323
+ frames: {},
324
+ createdAtMs: now,
325
+ updatedAtMs: now,
326
+ expiresAtMs: now + VIDEO_UPLOAD_V2_TTL_MS,
327
+ }
328
+ mkdirSync(this.dir(uploadId), { recursive: false, mode: 0o700 })
329
+ mkdirSync(this.originalDir(uploadId), { mode: 0o700 })
330
+ mkdirSync(this.frameDir(uploadId), { mode: 0o700 })
331
+ this.save(manifest)
332
+ this.manifests.set(uploadId, manifest)
333
+ this.byClientRequest.set(clientRequestId, uploadId)
334
+ return this.progress(manifest)
335
+ }
336
+
337
+ get(uploadId: string, serverInstanceId?: string): VideoUploadProgress {
338
+ const manifest = this.require(uploadId, serverInstanceId)
339
+ return this.progress(manifest)
340
+ }
341
+
342
+ async putOriginal(uploadId: string, indexValue: unknown, bytes: Buffer, serverInstanceId?: string): Promise<VideoUploadProgress> {
343
+ return this.putPart(uploadId, 'original', indexValue, bytes, serverInstanceId)
344
+ }
345
+
346
+ async putFrame(uploadId: string, indexValue: unknown, bytes: Buffer, serverInstanceId?: string): Promise<VideoUploadProgress> {
347
+ if (!phoneVideoFramesEnabled()) throw new VideoUploadError('video_upload_disabled', 'phone frame acceleration is disabled')
348
+ return this.putPart(uploadId, 'frames', indexValue, bytes, serverInstanceId)
349
+ }
350
+
351
+ async finalize(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress> {
352
+ const existing = this.finalizers.get(uploadId)
353
+ if (existing) return existing
354
+ const task = this.finalizeOnce(uploadId, serverInstanceId)
355
+ this.finalizers.set(uploadId, task)
356
+ try { return await task } finally {
357
+ if (this.finalizers.get(uploadId) === task) this.finalizers.delete(uploadId)
358
+ }
359
+ }
360
+
361
+ async acknowledge(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress> {
362
+ return this.withLock(uploadId, async () => {
363
+ const manifest = this.require(uploadId, serverInstanceId)
364
+ if (manifest.state !== 'published' || !manifest.receipt) {
365
+ throw new VideoUploadError('video_upload_incomplete', 'upload has no terminal receipt')
366
+ }
367
+ manifest.acknowledged = true
368
+ manifest.updatedAtMs = this.now()
369
+ manifest.expiresAtMs = manifest.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
370
+ this.save(manifest)
371
+ this.removeBodies(manifest)
372
+ return this.progress(manifest)
373
+ })
374
+ }
375
+
376
+ async cancel(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress | null> {
377
+ return this.withLock(uploadId, async () => {
378
+ const manifest = this.manifests.get(uploadId)
379
+ if (!manifest) return null
380
+ this.assertIdentity(manifest, serverInstanceId)
381
+ if (manifest.state === 'published' && manifest.receipt) {
382
+ await getMediaStore().deleteExactlyStaged(manifest.receipt.id)
383
+ }
384
+ manifest.state = 'cancelled'
385
+ manifest.generation++
386
+ manifest.updatedAtMs = this.now()
387
+ manifest.expiresAtMs = manifest.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
388
+ this.save(manifest)
389
+ this.removeBodies(manifest)
390
+ return this.progress(manifest)
391
+ })
392
+ }
393
+
394
+ status(): VideoUploadStatus {
395
+ this.sweepExpired()
396
+ let receiving = 0; let finalizing = 0; let unacknowledgedPublished = 0; let failed = 0
397
+ for (const item of this.manifests.values()) {
398
+ if (item.state === 'receiving') receiving++
399
+ else if (item.state === 'finalizing') finalizing++
400
+ else if (item.state === 'published' && !item.acknowledged) unacknowledgedPublished++
401
+ else if (item.state === 'failed') failed++
402
+ }
403
+ return {
404
+ protocol: 1,
405
+ enabled: videoUploadV2Enabled(),
406
+ receiving,
407
+ finalizing,
408
+ unacknowledgedPublished,
409
+ failed,
410
+ blocksRestart: receiving + finalizing > 0,
411
+ blocksRollback: receiving + finalizing + unacknowledgedPublished > 0,
412
+ }
413
+ }
414
+
415
+ sweepExpired(at = this.now()): number {
416
+ let swept = 0
417
+ for (const manifest of [...this.manifests.values()]) {
418
+ if (manifest.expiresAtMs > at || this.activeWriters.get(manifest.uploadId)) continue
419
+ // A published asset belongs to MediaStore, not the upload registry. The
420
+ // receipt is retained for 24 hours so a phone that lost the finalize
421
+ // response can recover it, but a phone/WebView that never sends ACK must
422
+ // not block server rollback forever. Expiring this manifest deliberately
423
+ // leaves the staged/reserved/associated media record untouched; MediaStore
424
+ // owns its normal lifecycle and GC from this point forward.
425
+ if (manifest.state === 'receiving' || manifest.state === 'failed' || manifest.state === 'cancelled'
426
+ || manifest.state === 'published') {
427
+ this.manifests.delete(manifest.uploadId)
428
+ this.byClientRequest.delete(manifest.clientRequestId)
429
+ rmSync(this.dir(manifest.uploadId), { recursive: true, force: true })
430
+ swept++
431
+ }
432
+ }
433
+ return swept
434
+ }
435
+
436
+ private async putPart(
437
+ uploadId: string,
438
+ kind: 'original' | 'frames',
439
+ indexValue: unknown,
440
+ bytes: Buffer,
441
+ serverInstanceId?: string,
442
+ ): Promise<VideoUploadProgress> {
443
+ const index = parseIndex(indexValue)
444
+ if (index === null || bytes.length === 0) throw new VideoUploadError('video_upload_invalid', 'valid non-empty part required')
445
+ const max = kind === 'original' ? VIDEO_UPLOAD_V2_CHUNK_BYTES : VIDEO_UPLOAD_V2_MAX_FRAME_BYTES
446
+ if (bytes.length > max) throw new VideoUploadError('video_upload_invalid', 'part exceeds its byte ceiling', { maxBytes: max })
447
+ this.activeWriters.set(uploadId, (this.activeWriters.get(uploadId) ?? 0) + 1)
448
+ try {
449
+ return await this.withLock(uploadId, () => {
450
+ const manifest = this.require(uploadId, serverInstanceId)
451
+ if (manifest.state !== 'receiving') throw new VideoUploadError('video_upload_busy', `upload is ${manifest.state}`)
452
+ if (kind === 'original' && index >= manifest.chunkCount) throw new VideoUploadError('video_upload_invalid', 'chunk index exceeds declared upload')
453
+ if (kind === 'frames' && index >= VIDEO_UPLOAD_PHONE_FRAMES_MAX) throw new VideoUploadError('video_upload_invalid', 'frame index exceeds pack limit')
454
+ const collection = manifest[kind]
455
+ const key = String(index)
456
+ const digest = sha256(bytes)
457
+ const accepted = collection[key]
458
+ if (accepted) {
459
+ if (accepted.bytes === bytes.length && accepted.sha256 === digest) return this.progress(manifest)
460
+ throw new VideoUploadError('video_upload_conflict', 'part index already contains different bytes')
461
+ }
462
+ if (kind === 'frames') {
463
+ const packBytes = Object.values(manifest.frames).reduce((sum, part) => sum + part.bytes, 0)
464
+ if (packBytes + bytes.length > VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES) {
465
+ throw new VideoUploadError('video_upload_invalid', 'frame pack exceeds byte ceiling')
466
+ }
467
+ }
468
+ const path = this.partPath(manifest, kind, index)
469
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
470
+ try {
471
+ const written = writeSync(fd, bytes)
472
+ if (written !== bytes.length) throw new Error(`short write: ${written}/${bytes.length}`)
473
+ fsyncSync(fd)
474
+ } finally { closeSync(fd) }
475
+ collection[key] = { bytes: bytes.length, sha256: digest }
476
+ manifest.updatedAtMs = this.now()
477
+ this.save(manifest)
478
+ return this.progress(manifest)
479
+ })
480
+ } finally {
481
+ const count = (this.activeWriters.get(uploadId) ?? 1) - 1
482
+ if (count <= 0) this.activeWriters.delete(uploadId)
483
+ else this.activeWriters.set(uploadId, count)
484
+ }
485
+ }
486
+
487
+ private async finalizeOnce(uploadId: string, serverInstanceId?: string): Promise<VideoUploadProgress> {
488
+ const manifest = await this.withLock(uploadId, () => {
489
+ const current = this.require(uploadId, serverInstanceId)
490
+ if (current.state === 'published' && current.receipt) return current
491
+ if (current.state === 'cancelled') throw new VideoUploadError('video_upload_cancelled', 'upload was cancelled')
492
+ if (this.activeWriters.get(uploadId)) throw new VideoUploadError('video_upload_busy', 'upload still has an active writer')
493
+ const missing = this.missingChunks(current)
494
+ if (missing.length > 0) throw new VideoUploadError('video_upload_incomplete', 'video upload is incomplete', { missingOriginalChunks: missing })
495
+ if (!current.mediaId) current.mediaId = `m_${randomBytes(12).toString('hex')}`
496
+ current.state = 'finalizing'
497
+ current.generation++
498
+ current.updatedAtMs = this.now()
499
+ this.save(current)
500
+ return current
501
+ })
502
+ if (manifest.state === 'published' && manifest.receipt) return this.progress(manifest)
503
+
504
+ const recovered = getMediaStore().findByVideoUploadId(uploadId)
505
+ if (recovered) return this.commitReceipt(uploadId, recovered.ref)
506
+
507
+ const assembledPath = join(this.dir(uploadId), 'original-assembled.bin')
508
+ try {
509
+ this.assembleOriginal(manifest, assembledPath)
510
+ const ref = await getMediaStore().ingestRichMediaFromFile({
511
+ sourcePath: assembledPath,
512
+ byteLength: manifest.totalBytes,
513
+ label: manifest.label,
514
+ declaredMime: manifest.mime,
515
+ capturedAt: manifest.capturedAt,
516
+ sessionId: manifest.sessionId,
517
+ transfer: 'chunked',
518
+ mediaId: manifest.mediaId,
519
+ videoUploadId: manifest.uploadId,
520
+ })
521
+ const current = this.manifests.get(uploadId)
522
+ if (current?.state === 'cancelled') {
523
+ await getMediaStore().deleteExactlyStaged(ref.id)
524
+ throw new VideoUploadError('video_upload_cancelled', 'upload was cancelled during finalization')
525
+ }
526
+ return this.commitReceipt(uploadId, ref)
527
+ } catch (error) {
528
+ if (error instanceof VideoUploadError && error.code === 'video_upload_cancelled') throw error
529
+ await this.withLock(uploadId, () => {
530
+ const current = this.manifests.get(uploadId)
531
+ if (current && current.state !== 'published' && current.state !== 'cancelled') {
532
+ current.state = 'failed'
533
+ current.failure = error instanceof Error ? error.message.slice(0, 160) : 'video finalization failed'
534
+ current.updatedAtMs = this.now()
535
+ this.save(current)
536
+ }
537
+ })
538
+ throw error
539
+ } finally {
540
+ try { rmSync(assembledPath, { force: true }) } catch { /* ingest may have moved it */ }
541
+ }
542
+ }
543
+
544
+ private async commitReceipt(uploadId: string, ref: MediaAttachmentRef): Promise<VideoUploadProgress> {
545
+ return this.withLock(uploadId, () => {
546
+ const current = this.require(uploadId)
547
+ if (current.state === 'cancelled') throw new VideoUploadError('video_upload_cancelled', 'upload was cancelled')
548
+ current.state = 'published'
549
+ current.receipt = ref
550
+ current.acknowledged = false
551
+ current.updatedAtMs = this.now()
552
+ current.expiresAtMs = current.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
553
+ this.save(current)
554
+ return this.progress(current)
555
+ })
556
+ }
557
+
558
+ private assembleOriginal(manifest: VideoUploadManifest, target: string): void {
559
+ rmSync(target, { force: true })
560
+ const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
561
+ let total = 0
562
+ try {
563
+ for (let index = 0; index < manifest.chunkCount; index++) {
564
+ const bytes = readFileSync(this.partPath(manifest, 'original', index))
565
+ const part = manifest.original[String(index)]
566
+ if (!part || bytes.length !== part.bytes || sha256(bytes) !== part.sha256) {
567
+ throw new VideoUploadError('video_upload_failed', `chunk ${index} failed integrity verification`)
568
+ }
569
+ const written = writeSync(fd, bytes)
570
+ if (written !== bytes.length) throw new Error(`short assembly write: ${written}/${bytes.length}`)
571
+ total += written
572
+ }
573
+ if (total !== manifest.totalBytes) throw new VideoUploadError('video_upload_failed', 'assembled video size mismatch')
574
+ fsyncSync(fd)
575
+ } finally { closeSync(fd) }
576
+ }
577
+
578
+ private load(): void {
579
+ for (const entry of readdirSync(this.root, { withFileTypes: true })) {
580
+ if (!entry.isDirectory() || !isValidVideoUploadId(entry.name)) continue
581
+ try {
582
+ const manifest = parseManifest(JSON.parse(readFileSync(join(this.root, entry.name, 'manifest.json'), 'utf8')))
583
+ if (!manifest) continue
584
+ this.manifests.set(manifest.uploadId, manifest)
585
+ this.byClientRequest.set(manifest.clientRequestId, manifest.uploadId)
586
+ } catch { /* preserve unreadable draft on disk; do not invent state */ }
587
+ }
588
+ }
589
+
590
+ private reconcilePublished(): void {
591
+ for (const manifest of this.manifests.values()) {
592
+ if (manifest.state !== 'finalizing' && manifest.state !== 'failed'
593
+ && !(manifest.state === 'published' && !manifest.receipt)) continue
594
+ const record = getMediaStore().findByVideoUploadId(manifest.uploadId)
595
+ if (record) {
596
+ manifest.state = 'published'
597
+ manifest.receipt = record.ref
598
+ manifest.acknowledged = false
599
+ manifest.updatedAtMs = this.now()
600
+ manifest.expiresAtMs = manifest.updatedAtMs + VIDEO_UPLOAD_V2_RECEIPT_TTL_MS
601
+ this.save(manifest)
602
+ } else if (manifest.state === 'finalizing') {
603
+ // Finalize was claimed but media publication never completed. The
604
+ // complete draft is durable, so reopen it for an idempotent retry.
605
+ manifest.state = 'receiving'
606
+ manifest.failure = undefined
607
+ manifest.generation++
608
+ manifest.updatedAtMs = this.now()
609
+ this.save(manifest)
610
+ }
611
+ }
612
+ }
613
+
614
+ private require(uploadId: string, serverInstanceId?: string): VideoUploadManifest {
615
+ if (!isValidVideoUploadId(uploadId)) throw new VideoUploadError('video_upload_not_found', 'unknown video upload')
616
+ const manifest = this.manifests.get(uploadId)
617
+ if (!manifest) throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
618
+ this.assertIdentity(manifest, serverInstanceId)
619
+ return manifest
620
+ }
621
+
622
+ private assertIdentity(manifest: VideoUploadManifest, serverInstanceId?: string): void {
623
+ if (serverInstanceId && serverInstanceId !== manifest.serverInstanceId) {
624
+ throw new VideoUploadError('server_identity_mismatch', 'video upload belongs to a different COS server')
625
+ }
626
+ }
627
+
628
+ private progress(manifest: VideoUploadManifest): VideoUploadProgress {
629
+ const receivedOriginalChunks = Object.keys(manifest.original).map(Number).sort((a, b) => a - b)
630
+ const receivedFrames = Object.keys(manifest.frames).map(Number).sort((a, b) => a - b)
631
+ return {
632
+ protocol: 1,
633
+ uploadId: manifest.uploadId,
634
+ serverInstanceId: manifest.serverInstanceId,
635
+ state: manifest.state,
636
+ totalBytes: manifest.totalBytes,
637
+ chunkBytes: manifest.chunkBytes,
638
+ chunkCount: manifest.chunkCount,
639
+ receivedOriginalChunks,
640
+ missingOriginalChunks: this.missingChunks(manifest),
641
+ receivedFrames,
642
+ expiresAt: new Date(manifest.expiresAtMs).toISOString(),
643
+ acknowledged: manifest.acknowledged === true,
644
+ ...(manifest.receipt ? { attachment: manifest.receipt } : {}),
645
+ ...(manifest.failure ? { failure: manifest.failure } : {}),
646
+ }
647
+ }
648
+
649
+ private missingChunks(manifest: VideoUploadManifest): number[] {
650
+ const missing: number[] = []
651
+ for (let index = 0; index < manifest.chunkCount; index++) {
652
+ if (!manifest.original[String(index)]) missing.push(index)
653
+ }
654
+ return missing
655
+ }
656
+
657
+ private save(manifest: VideoUploadManifest): void {
658
+ durableAtomicWriteFileSync(join(this.dir(manifest.uploadId), 'manifest.json'), JSON.stringify(manifest), { mode: 0o600 })
659
+ }
660
+
661
+ private removeBodies(manifest: VideoUploadManifest): void {
662
+ rmSync(this.originalDir(manifest.uploadId), { recursive: true, force: true })
663
+ rmSync(this.frameDir(manifest.uploadId), { recursive: true, force: true })
664
+ }
665
+
666
+ private dir(uploadId: string): string { return join(this.root, uploadId) }
667
+ private originalDir(uploadId: string): string { return join(this.dir(uploadId), 'original') }
668
+ private frameDir(uploadId: string): string { return join(this.dir(uploadId), 'frames') }
669
+ private partPath(manifest: VideoUploadManifest, kind: 'original' | 'frames', index: number): string {
670
+ return join(kind === 'original' ? this.originalDir(manifest.uploadId) : this.frameDir(manifest.uploadId), `${index}.bin`)
671
+ }
672
+
673
+ private withLock<T>(uploadId: string, work: () => T | Promise<T>): Promise<T> {
674
+ const prior = this.locks.get(uploadId) ?? Promise.resolve()
675
+ const run = prior.then(work, work)
676
+ this.locks.set(uploadId, run.then(() => undefined, () => undefined))
677
+ return run
678
+ }
679
+ }
680
+
681
+ let defaultRegistry: VideoUploadRegistry | null = null
682
+
683
+ export function getVideoUploadRegistry(): VideoUploadRegistry {
684
+ if (!defaultRegistry) defaultRegistry = new VideoUploadRegistry()
685
+ return defaultRegistry
686
+ }
687
+
688
+ export function _setVideoUploadRegistryForTests(registry: VideoUploadRegistry | null): VideoUploadRegistry | null {
689
+ const previous = defaultRegistry
690
+ defaultRegistry = registry
691
+ return previous
692
+ }
693
+
694
+ export function videoUploadV2Capability(videoProcessingReady: boolean) {
695
+ let registryReady = false
696
+ let reason = 'disabled'
697
+ if (videoUploadV2Enabled()) {
698
+ try {
699
+ getVideoUploadRegistry()
700
+ registryReady = true
701
+ reason = videoProcessingReady ? 'ready' : 'video_processing_unavailable'
702
+ } catch {
703
+ reason = 'storage_unavailable'
704
+ }
705
+ }
706
+ return {
707
+ available: registryReady && videoProcessingReady,
708
+ protocol: VIDEO_UPLOAD_V2_PROTOCOL,
709
+ chunkBytes: VIDEO_UPLOAD_V2_CHUNK_BYTES,
710
+ maxOriginalBytes: MAX_CHUNKED_MEDIA_BYTES,
711
+ maxDurationMs: MAX_VIDEO_DURATION_MS,
712
+ acceptedMimes: [...VIDEO_UPLOAD_V2_ACCEPTED_MIMES],
713
+ // The route and manifest format are present so the phone implementation can
714
+ // be canaried without another wire change. Do not advertise frame packs as
715
+ // usable until the server consumes them and physical WKWebView proof passes.
716
+ phoneFramesAvailable: false,
717
+ phoneFramesMin: VIDEO_UPLOAD_PHONE_FRAMES_MIN,
718
+ phoneFramesMax: VIDEO_UPLOAD_PHONE_FRAMES_MAX,
719
+ serverFramesMin: VIDEO_UPLOAD_SERVER_FRAMES_MIN,
720
+ serverFramesMax: VIDEO_UPLOAD_SERVER_FRAMES_MAX,
721
+ maxFrameBytes: VIDEO_UPLOAD_V2_MAX_FRAME_BYTES,
722
+ maxPackBytes: VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES,
723
+ maxSourcePixels: 33_177_600,
724
+ reason,
725
+ }
726
+ }
@@ -59,6 +59,8 @@ import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
59
59
  import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
60
60
  import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
61
61
  import { resolveMeetingLibrary } from '../lib/cos-operations-meetings.js'
62
+ import { videoUploadV2Capability } from '../lib/video-upload-v2.js'
63
+ import { MAX_MODEL_IMAGE_INPUTS } from '../lib/query-attachments.js'
62
64
 
63
65
  export const healthRouter = Router()
64
66
 
@@ -172,6 +174,7 @@ healthRouter.get('/health', async (_req, res) => {
172
174
  // capabilities.liveCues so the two surfaces can never disagree.
173
175
  const liveCues = liveCuesCapability()
174
176
  const richMedia = await getRichMediaProcessingCapabilities()
177
+ const videoUploadV2 = videoUploadV2Capability(richMedia.video)
175
178
  // Upload limits are published so the client never carries its own byte caps:
176
179
  // cos-glasses-app and cos-glasses-server are separate repos that have already
177
180
  // diverged, so a constant in both is guaranteed to drift. Absent means an
@@ -326,6 +329,11 @@ healthRouter.get('/health', async (_req, res) => {
326
329
  maxVideoBytesPerAttachment: MAX_VIDEO_MEDIA_BYTES,
327
330
  maxVideoMinutes: 20,
328
331
  maxStillFrames: 8,
332
+ videoUploadV2: {
333
+ available: videoUploadV2.available,
334
+ protocol: videoUploadV2.protocol,
335
+ reason: videoUploadV2.reason,
336
+ },
329
337
  },
330
338
  meetingLifecycle: {
331
339
  earlySyncClaim: getEarlyMeetingSyncSnapshot(),
@@ -359,6 +367,8 @@ healthRouter.get('/models', async (req, res) => {
359
367
  const transcriptionLive = getWhisperPreviewCapability()
360
368
  const transcriptionProfile = getTranscriptionProfileStatus()
361
369
  const progressiveHq = getProgressiveHqSnapshot()
370
+ const richMedia = await getRichMediaProcessingCapabilities()
371
+ const videoUploadV2 = videoUploadV2Capability(richMedia.video)
362
372
  const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
363
373
  res.json({
364
374
  ...catalog,
@@ -391,6 +401,20 @@ healthRouter.get('/models', async (req, res) => {
391
401
  // THIS surface, so a value present only on /api/health leaves the
392
402
  // live-cues indicator blind.
393
403
  liveCues: liveCuesCapability(),
404
+ richMedia: {
405
+ video: richMedia.video,
406
+ maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
407
+ videoUploadV2: {
408
+ ...videoUploadV2,
409
+ // The first private train deliberately admits only one video and no
410
+ // other visual attachment. This keeps a server fallback at its
411
+ // proven 16-frame policy inside the provider-wide 16-image ceiling.
412
+ promptAdmission: {
413
+ maxVideos: 1,
414
+ maxOtherVisualsWithVideo: 0,
415
+ },
416
+ },
417
+ },
394
418
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
395
419
  },
396
420
  })
@@ -16,6 +16,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
16
16
  import { getServerInstanceId } from '../lib/server-instance-id.js'
17
17
  import { getWhisperHealth } from '../lib/whisper-local.js'
18
18
  import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
19
+ import { getVideoUploadRegistry } from '../lib/video-upload-v2.js'
19
20
 
20
21
  export const maintenanceRouter = Router()
21
22
 
@@ -78,6 +79,21 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
78
79
  // rather than counted, and are never deleted here.
79
80
  const sessionLiveness = getTranscriptionSessionLiveness()
80
81
  const managed = managedRuntimeCapability()
82
+ let videoUploads
83
+ try {
84
+ videoUploads = getVideoUploadRegistry().status()
85
+ } catch {
86
+ videoUploads = {
87
+ protocol: 1 as const,
88
+ enabled: false,
89
+ receiving: 0,
90
+ finalizing: 0,
91
+ unacknowledgedPublished: 0,
92
+ failed: 0,
93
+ blocksRestart: false,
94
+ blocksRollback: false,
95
+ }
96
+ }
81
97
  const tracked = maintenanceLifecycle.snapshot(credentials, {
82
98
  recording_session: sessionLiveness.live,
83
99
  })
@@ -107,7 +123,10 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
107
123
  shuttingDown: jobs.shuttingDown,
108
124
  durableStoreState: jobs.store.state,
109
125
  lifecycle,
110
- safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown,
126
+ videoUploads,
127
+ // Published receipts intentionally survive an ordinary restart. They only
128
+ // block a binary downgrade, where an old server could erase V2 evidence.
129
+ safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown && !videoUploads.blocksRestart,
111
130
  whisper: getWhisperHealth(),
112
131
  }
113
132
  }
@@ -57,6 +57,16 @@ import {
57
57
  isValidUploadId,
58
58
  type UploadSessionErrorCode,
59
59
  } from '../lib/upload-session.js'
60
+ import {
61
+ VIDEO_UPLOAD_V2_CHUNK_BYTES,
62
+ VIDEO_UPLOAD_V2_MAX_FRAME_BYTES,
63
+ VideoUploadError,
64
+ getVideoUploadRegistry,
65
+ isValidVideoUploadId,
66
+ type VideoUploadErrorCode,
67
+ } from '../lib/video-upload-v2.js'
68
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
69
+ import { maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
60
70
 
61
71
  // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
62
72
  // overhead. Mounted only for /api/media in server/index.ts — the global
@@ -301,6 +311,9 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
301
311
 
302
312
  /** Chunk parser at the advertised chunk size. */
303
313
  export const mediaChunkBodyParser = createMediaChunkBodyParser()
314
+ export const videoUploadChunkBodyParser = createMediaChunkBodyParser({
315
+ maxChunkBytes: Math.max(VIDEO_UPLOAD_V2_CHUNK_BYTES, VIDEO_UPLOAD_V2_MAX_FRAME_BYTES),
316
+ })
304
317
 
305
318
  function takeChunkBody(req: Request): Buffer | undefined {
306
319
  const bytes = chunkBodies.get(req)
@@ -347,6 +360,19 @@ const UPLOAD_ERROR_STATUS: Record<UploadSessionErrorCode, number> = {
347
360
  upload_staging_failed: 500,
348
361
  }
349
362
 
363
+ const VIDEO_UPLOAD_ERROR_STATUS: Record<VideoUploadErrorCode, number> = {
364
+ video_upload_disabled: 503,
365
+ video_upload_not_found: 404,
366
+ video_upload_conflict: 409,
367
+ video_upload_busy: 409,
368
+ video_upload_incomplete: 409,
369
+ video_upload_invalid: 400,
370
+ video_upload_quota: 507,
371
+ video_upload_cancelled: 410,
372
+ video_upload_failed: 500,
373
+ server_identity_mismatch: 409,
374
+ }
375
+
350
376
  function sendMediaError(res: Response, err: unknown): void {
351
377
  // Chunked-upload failures go through the SAME funnel as every other media
352
378
  // error, so there is one place that decides the error body's shape.
@@ -354,6 +380,14 @@ function sendMediaError(res: Response, err: unknown): void {
354
380
  res.status(UPLOAD_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, ...err.detail })
355
381
  return
356
382
  }
383
+ if (err instanceof VideoUploadError) {
384
+ res.status(VIDEO_UPLOAD_ERROR_STATUS[err.code] ?? 500).json({
385
+ error: err.code,
386
+ detail: err.message,
387
+ ...err.detail,
388
+ })
389
+ return
390
+ }
357
391
  if (err instanceof MediaStoreError) {
358
392
  res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
359
393
  return
@@ -374,6 +408,26 @@ function sendMediaError(res: Response, err: unknown): void {
374
408
  res.status(500).json({ error: 'media_internal_error' })
375
409
  }
376
410
 
411
+ function videoUploadServerIdentity(req: Request): string | undefined {
412
+ return safeString(req.header('x-cos-server-instance'), 160)
413
+ ?? safeString(req.header('x-cos-server-instance-id'), 160)
414
+ }
415
+
416
+ function requireVideoUploadAdmission(res: Response): boolean {
417
+ if (maintenanceAdmissionsOpen()) return true
418
+ res.status(503).json({ error: 'server_maintenance', retryable: true })
419
+ return false
420
+ }
421
+
422
+ function requireCurrentServerIdentity(req: Request): string {
423
+ const presented = videoUploadServerIdentity(req)
424
+ const current = getServerInstanceId()
425
+ if (!presented || presented !== current) {
426
+ throw new VideoUploadError('server_identity_mismatch', 'request targets a different COS server')
427
+ }
428
+ return presented
429
+ }
430
+
377
431
  function safeString(v: unknown, max: number): string | undefined {
378
432
  return typeof v === 'string' && v.trim().length > 0 ? v.trim().slice(0, max) : undefined
379
433
  }
@@ -489,6 +543,124 @@ function uploadLabelFrom(req: Request): string | undefined {
489
543
  try { return decodeURIComponent(rawLabel).slice(0, 120) } catch { return rawLabel.slice(0, 120) }
490
544
  }
491
545
 
546
+ // ── Resumable video upload V2 (private canary) ───────────────────────────────
547
+
548
+ mediaRouter.post('/media/video-upload/init', (req: Request, res: Response) => {
549
+ try {
550
+ const serverInstanceId = requireCurrentServerIdentity(req)
551
+ const body = req.body ?? {}
552
+ const progress = getVideoUploadRegistry().init({
553
+ clientRequestId: body.clientRequestId,
554
+ serverInstanceId,
555
+ totalBytes: body.totalBytes,
556
+ mime: body.mime,
557
+ label: uploadLabelFrom(req),
558
+ capturedAt: safeString(req.header('x-cos-captured-at'), 40),
559
+ sessionId: safeString(req.header('x-cos-session-id'), 64),
560
+ })
561
+ res.json(progress)
562
+ } catch (err) {
563
+ sendMediaError(res, err)
564
+ }
565
+ })
566
+
567
+ mediaRouter.get('/media/video-upload/:uploadId', (req: Request, res: Response) => {
568
+ try {
569
+ const serverInstanceId = requireCurrentServerIdentity(req)
570
+ if (!isValidVideoUploadId(req.params.uploadId)) {
571
+ throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
572
+ }
573
+ res.json(getVideoUploadRegistry().get(req.params.uploadId, serverInstanceId))
574
+ } catch (err) {
575
+ sendMediaError(res, err)
576
+ }
577
+ })
578
+
579
+ mediaRouter.put(
580
+ '/media/video-upload/:uploadId/original/:index',
581
+ videoUploadChunkBodyParser,
582
+ async (req: Request, res: Response) => {
583
+ const bytes = takeChunkBody(req)
584
+ try {
585
+ if (!requireVideoUploadAdmission(res)) return
586
+ const serverInstanceId = requireCurrentServerIdentity(req)
587
+ if (!isValidVideoUploadId(req.params.uploadId) || bytes === undefined) {
588
+ throw new VideoUploadError('video_upload_invalid', 'valid upload id and raw chunk bytes are required')
589
+ }
590
+ res.json(await getVideoUploadRegistry().putOriginal(
591
+ req.params.uploadId,
592
+ req.params.index,
593
+ bytes,
594
+ serverInstanceId,
595
+ ))
596
+ } catch (err) {
597
+ sendMediaError(res, err)
598
+ }
599
+ },
600
+ )
601
+
602
+ mediaRouter.put(
603
+ '/media/video-upload/:uploadId/frame/:index',
604
+ videoUploadChunkBodyParser,
605
+ async (req: Request, res: Response) => {
606
+ const bytes = takeChunkBody(req)
607
+ try {
608
+ if (!requireVideoUploadAdmission(res)) return
609
+ const serverInstanceId = requireCurrentServerIdentity(req)
610
+ if (!isValidVideoUploadId(req.params.uploadId) || bytes === undefined) {
611
+ throw new VideoUploadError('video_upload_invalid', 'valid upload id and raw frame bytes are required')
612
+ }
613
+ res.json(await getVideoUploadRegistry().putFrame(
614
+ req.params.uploadId,
615
+ req.params.index,
616
+ bytes,
617
+ serverInstanceId,
618
+ ))
619
+ } catch (err) {
620
+ sendMediaError(res, err)
621
+ }
622
+ },
623
+ )
624
+
625
+ mediaRouter.post('/media/video-upload/:uploadId/finalize', async (req: Request, res: Response) => {
626
+ try {
627
+ if (!requireVideoUploadAdmission(res)) return
628
+ const serverInstanceId = requireCurrentServerIdentity(req)
629
+ if (!isValidVideoUploadId(req.params.uploadId)) {
630
+ throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
631
+ }
632
+ res.json(await getVideoUploadRegistry().finalize(req.params.uploadId, serverInstanceId))
633
+ } catch (err) {
634
+ sendMediaError(res, err)
635
+ }
636
+ })
637
+
638
+ mediaRouter.post('/media/video-upload/:uploadId/ack', async (req: Request, res: Response) => {
639
+ try {
640
+ const serverInstanceId = requireCurrentServerIdentity(req)
641
+ if (!isValidVideoUploadId(req.params.uploadId)) {
642
+ throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
643
+ }
644
+ res.json(await getVideoUploadRegistry().acknowledge(req.params.uploadId, serverInstanceId))
645
+ } catch (err) {
646
+ sendMediaError(res, err)
647
+ }
648
+ })
649
+
650
+ mediaRouter.delete('/media/video-upload/:uploadId', async (req: Request, res: Response) => {
651
+ try {
652
+ const serverInstanceId = requireCurrentServerIdentity(req)
653
+ if (!isValidVideoUploadId(req.params.uploadId)) {
654
+ res.json({ ok: true, dropped: false })
655
+ return
656
+ }
657
+ const progress = await getVideoUploadRegistry().cancel(req.params.uploadId, serverInstanceId)
658
+ res.json({ ok: true, dropped: progress !== null, ...(progress ? { upload: progress } : {}) })
659
+ } catch (err) {
660
+ sendMediaError(res, err)
661
+ }
662
+ })
663
+
492
664
  mediaRouter.post('/media/upload/init', (req: Request, res: Response) => {
493
665
  try {
494
666
  const body = req.body ?? {}