@gotcos/glasses-server 6.27.4 → 6.27.6

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/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## 6.27.6
2
+ - **V2 original chunks are 1 MiB.** Same sequential one-in-flight loop, same
3
+ ArrayBuffer bodies, same GET-progress resume. A 244 MB clip goes from 953
4
+ round trips to ~239. That is the leftover ~10% against legacy 8 MiB, paid as
5
+ per-chunk RTT, without opening a second fetch — two concurrent ArrayBuffer
6
+ PUTs from this WebView are still an untested shape.
7
+ - **In-flight 256 KiB drafts keep their size.** `putOriginal` checks the
8
+ session's own `chunkBytes`, not the live constant, so a draft that started
9
+ before this upgrade still accepts 256 KiB parts and rejects a 1 MiB PUT into
10
+ that slot. New inits advertise 1 MiB. Do not raise this above 1 MiB until the
11
+ phone parser cap is raised first — above that, V2 capability parse returns
12
+ null and the transport silently falls back to legacy.
13
+ - Frame parts stay 256 KiB. Protocol stays 1.
14
+
15
+ ## 6.27.5
16
+ - **Chunk uploads now leave a server-side trace.** On 2026-08-12 a phone upload
17
+ stalled on both media transports and the server was a complete blind spot: nothing
18
+ recorded that a chunk request had arrived, so "the client never got the ack" could
19
+ not be separated from "the server never sent one" without reading a staging file's
20
+ mtime and running `netstat` by hand.
21
+ - `[media-chunk]` now logs one line per REQUEST (never per data event - a 237-chunk
22
+ upload logging per `data` would bury the log): `body-read` when the body finishes
23
+ arriving, `responded` with status when the response is fully flushed to the socket,
24
+ `abandoned` when the client goes away mid-body, and `closed-unanswered` when the
25
+ socket closes with no response written. Each carries bytes received and elapsed ms.
26
+ - `closed-unanswered` is the only case where blaming the server is correct, and it is
27
+ now stated explicitly rather than inferred from absence of evidence. It gates on
28
+ **`res` 'close', not `req` 'close'**: since Node 16 `IncomingMessage` emits 'close'
29
+ when the REQUEST completes rather than when the socket does, so on an async handler
30
+ (`putOriginal` and `putFrame` both are) it lands while `res.writableEnded` is still
31
+ false. Gated on `req` it false-fired on EVERY successful V2 chunk — 237 bogus alarms
32
+ per upload on the single most diagnostic line in the file. Caught by adversarial QA
33
+ before release and pinned by a test that fails if the gate moves back.
34
+ - The two early refusals (`chunk_bytes_required` 400 and declared-Content-Length 413)
35
+ returned BEFORE the tracer existed, so a chunk the server actively rejected logged
36
+ nothing — the same silence as a request that never arrived. The tracer is now
37
+ declared above them and every refusal logs.
38
+ - Diagnosis only. No behaviour change to any upload path.
39
+
1
40
  ## 6.27.4
2
41
 
3
42
  ### Fuzzy name corrections reach phone dictation
package/README.md CHANGED
@@ -180,7 +180,8 @@ machine-wide rollback for build 204+ server-owned query recovery),
180
180
  `COS_MEDIA_ROOT` (optional image/video store location; default
181
181
  `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
182
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
183
+ accepted original chunks (1 MiB on new sessions; leftover 256 KiB drafts keep
184
+ that size) and finalize receipts across restarts; keep it off when
184
185
  using an older companion. Your name + transcription vocabulary live in
185
186
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
186
187
  Factory example values are ignored; add the real names, companies, acronyms,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.4",
3
+ "version": "6.27.6",
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,7 +162,8 @@ 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
165
+ // V2 video original chunks are bounded to the advertised session size (1 MiB
166
+ // on new sessions, 256 KiB on leftover drafts) and commit through the upload
166
167
  // registry's own generation lock. Holding the global mutation lease while
167
168
  // the phone transfers the body recreated the exact 90-second drain failure
168
169
  // this protocol exists to remove. Admission is checked by the route before
@@ -27,7 +27,12 @@ import { getMediaStore } from './media-store.js'
27
27
  import { MAX_CHUNKED_MEDIA_BYTES, MAX_VIDEO_DURATION_MS } from './rich-media-safety.js'
28
28
 
29
29
  export const VIDEO_UPLOAD_V2_PROTOCOL = 1
30
- export const VIDEO_UPLOAD_V2_CHUNK_BYTES = 256 * 1024
30
+ /** New sessions only. In-flight drafts keep the chunkBytes baked into their
31
+ * manifest — a 256 KiB upload that survives this upgrade must not be rewritten
32
+ * to 1 MiB mid-transfer. The phone parser currently rejects advertised sizes
33
+ * above 1 MiB and disables V2 entirely, so do not raise this without raising
34
+ * that cap first. */
35
+ export const VIDEO_UPLOAD_V2_CHUNK_BYTES = 1024 * 1024
31
36
  export const VIDEO_UPLOAD_V2_MAX_FRAME_BYTES = 256 * 1024
32
37
  export const VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES = 2 * 1024 * 1024
33
38
  export const VIDEO_UPLOAD_PHONE_FRAMES_MIN = 8
@@ -177,6 +182,17 @@ function parseIndex(value: unknown): number | null {
177
182
  return typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0 ? raw : null
178
183
  }
179
184
 
185
+ /** Exact byte length this session expects for original index `index`.
186
+ * Non-final parts are the session's own chunkBytes (which may be 256 KiB on a
187
+ * draft that started before the 1 MiB advertisement). The last part is the
188
+ * remainder. Using the live constant here would accept a 1 MiB PUT into a
189
+ * 256 KiB slot and fail assembly, or reject a legitimate leftover last chunk. */
190
+ function expectedOriginalPartBytes(manifest: VideoUploadManifest, index: number): number {
191
+ if (index < 0 || index >= manifest.chunkCount) return 0
192
+ if (index === manifest.chunkCount - 1) return manifest.totalBytes - index * manifest.chunkBytes
193
+ return manifest.chunkBytes
194
+ }
195
+
180
196
  function sameInit(manifest: VideoUploadManifest, input: Required<Pick<VideoUploadManifest,
181
197
  'serverInstanceId' | 'totalBytes' | 'mime'>> & Pick<VideoUploadManifest, 'label' | 'capturedAt' | 'sessionId'>): boolean {
182
198
  return manifest.serverInstanceId === input.serverInstanceId
@@ -442,8 +458,8 @@ export class VideoUploadRegistry {
442
458
  ): Promise<VideoUploadProgress> {
443
459
  const index = parseIndex(indexValue)
444
460
  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 })
461
+ const advertisedMax = kind === 'original' ? VIDEO_UPLOAD_V2_CHUNK_BYTES : VIDEO_UPLOAD_V2_MAX_FRAME_BYTES
462
+ if (bytes.length > advertisedMax) throw new VideoUploadError('video_upload_invalid', 'part exceeds its byte ceiling', { maxBytes: advertisedMax })
447
463
  this.activeWriters.set(uploadId, (this.activeWriters.get(uploadId) ?? 0) + 1)
448
464
  try {
449
465
  return await this.withLock(uploadId, () => {
@@ -451,6 +467,14 @@ export class VideoUploadRegistry {
451
467
  if (manifest.state !== 'receiving') throw new VideoUploadError('video_upload_busy', `upload is ${manifest.state}`)
452
468
  if (kind === 'original' && index >= manifest.chunkCount) throw new VideoUploadError('video_upload_invalid', 'chunk index exceeds declared upload')
453
469
  if (kind === 'frames' && index >= VIDEO_UPLOAD_PHONE_FRAMES_MAX) throw new VideoUploadError('video_upload_invalid', 'frame index exceeds pack limit')
470
+ if (kind === 'original') {
471
+ const expected = expectedOriginalPartBytes(manifest, index)
472
+ if (bytes.length !== expected) {
473
+ throw new VideoUploadError('video_upload_invalid', 'part does not match the session chunk size', {
474
+ expectedBytes: expected, receivedBytes: bytes.length, chunkBytes: manifest.chunkBytes,
475
+ })
476
+ }
477
+ }
454
478
  const collection = manifest[kind]
455
479
  const key = String(index)
456
480
  const digest = sha256(bytes)
@@ -255,7 +255,42 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
255
255
  return
256
256
  }
257
257
 
258
+ // Chunk-upload lifecycle trace.
259
+ //
260
+ // WHY. On 2026-08-12 a phone upload stalled on BOTH transports and the server side
261
+ // was a complete blind spot: nothing recorded that a chunk request had even
262
+ // arrived, so "the client never got the ack" could not be separated from "the
263
+ // server never sent one" without reading a staging file's mtime and running
264
+ // netstat by hand.
265
+ //
266
+ // SAMPLED like the client's breadcrumbs, and for the same reason. One line per
267
+ // REQUEST is still 2 rows x 248 chunks per video (~500), and at the advertised
268
+ // 2 GB ceiling ~16,400 rows and ~1.5 MB of log for ONE upload, into a LaunchAgent
269
+ // stdout with no rotation. Routine progress is sampled; the DIAGNOSTIC events —
270
+ // abandoned, closed-unanswered, too-large, and any non-2xx — are never sampled
271
+ // away, because those are the rows an investigation actually needs.
272
+ //
273
+ // Declared ABOVE the early refusals below: those used to return before this
274
+ // existed, so a chunk the server actively refused logged nothing at all — the
275
+ // same silence as a request that never arrived.
276
+ const openedAt = Date.now()
277
+ // `?? ''` is load-bearing: a request with neither field (the direct-parser test
278
+ // harness, and any non-Express caller) made `path.slice` throw and took the whole
279
+ // parser down. A tracer must never be able to break the thing it observes.
280
+ const path = req.originalUrl?.split('?')[0] ?? req.url ?? ''
281
+ const traceTarget = `${req.method} ${path}`
282
+ // Trailing segment is the chunk index on both routes
283
+ // (`/media/upload/:id/:index` and `/media/video-upload/:id/original/:index`).
284
+ const chunkIndex = Number(path.slice(path.lastIndexOf('/') + 1))
285
+ const sampled = !Number.isFinite(chunkIndex) || chunkIndex === 0 || chunkIndex % 32 === 0
286
+ let received = 0
287
+ const logLifecycle = (event: string, extra = '', always = false): void => {
288
+ if (!always && !sampled) return
289
+ console.log(`[media-chunk] ${event} ${traceTarget} bytes=${received} +${Date.now() - openedAt}ms${extra}`)
290
+ }
291
+
258
292
  const refuse = (status: number, body: Record<string, unknown>): void => {
293
+ logLifecycle('refused', ` status=${status} error=${String(body.error ?? '')}`, true)
259
294
  res.status(status).json(body)
260
295
  res.once('finish', () => req.destroy())
261
296
  }
@@ -279,7 +314,6 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
279
314
  }
280
315
 
281
316
  const parts: Buffer[] = []
282
- let received = 0
283
317
  let settled = false
284
318
 
285
319
  req.on('data', (chunk: Buffer) => {
@@ -288,6 +322,7 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
288
322
  if (received > maxChunkBytes) {
289
323
  settled = true
290
324
  parts.length = 0
325
+ logLifecycle('too-large', ` max=${maxChunkBytes}`)
291
326
  refuse(413, { error: 'attachment_too_large', maxBytes: maxChunkBytes })
292
327
  return
293
328
  }
@@ -297,12 +332,33 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
297
332
  if (settled) return
298
333
  settled = true
299
334
  parts.length = 0
335
+ // The client went away mid-body. Distinguishes a phone that stopped sending
336
+ // from a server that stopped answering — opposite diagnoses, same silence.
337
+ logLifecycle('abandoned')
300
338
  }
301
339
  req.once('aborted', abandon)
302
340
  req.once('error', abandon)
341
+ // Fires when the response is fully flushed to the socket. Paired with 'body-read'
342
+ // this is the proof that the server answered, and how fast.
343
+ res.once('finish', () => {
344
+ logLifecycle('responded', ` status=${res.statusCode}`)
345
+ })
346
+ // `res` 'close', NOT `req` 'close'. Since Node 16 `IncomingMessage` emits 'close'
347
+ // when the REQUEST completes, not when the socket does — so on any async handler
348
+ // it fires while `res.writableEnded` is still false. `putOriginal`/`putFrame` are
349
+ // both async, so gating on `req` made this false-fire on EVERY successful V2
350
+ // chunk: 237 bogus rows per upload, on the single line the changelog calls "the
351
+ // only case where blaming the server is correct". A watcher that cries wolf on the
352
+ // happy path is worse than no watcher. `res` 'close' fires after the response is
353
+ // finished or genuinely aborted, so the guard means what it says.
354
+ res.once('close', () => {
355
+ if (res.writableEnded) return
356
+ logLifecycle('closed-unanswered', '', true)
357
+ })
303
358
  req.once('end', () => {
304
359
  if (settled) return
305
360
  settled = true
361
+ logLifecycle('body-read')
306
362
  chunkBodies.set(req, Buffer.concat(parts))
307
363
  next()
308
364
  })