@gotcos/glasses-server 6.50.3 → 6.50.4
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,10 @@
|
|
|
1
|
+
## 6.50.4
|
|
2
|
+
|
|
3
|
+
- **The ring never moves in a way that stops the only recording.** A copy told to yield stops its recording (that is how a duplicate ends). QA of 6.50.3, with glasses 6.9.507 on the phone: reopening COS on a blank phone page mid-meeting (the 2026-09-18 08:15 symptom) gave the ring to the newer copy, so the meeting ended within 15 s and the new window was not recording. The referee now notes every meeting-chunk POST as it passes: a live chunk, or an iPhone offline chunk replayed; the preview does not count. The router is mounted before the transcribe-stream router and only notes, never answers. It records whose chunk it was (`?clientInstance=`, glasses 6.9.509+) or, for an untagged chunk, which device sent it. Two rules follow. (1) While the owner's OWN chunks arrive (30 s window, `CLIENT_INSTANCE_CAPTURE_LIVE_MS`), a newer copy is told to wait, however quiet the owner's claims. A hidden or locked WebView throttles its timers, but chunk uploads follow the audio. The ring moves on the first claim after the meeting stops. (2) A claimant whose own chunks arrive while the owner's do not takes the ring. That covers the case QA found in the first draft of this release, which held the ring on ANY chunk: COS reopened offline, a meeting started on the new copy, and on reconnect that copy's own chunks switched the hold on for the dead copy the Mac still remembered, so its claim was told to yield and the only recording stopped. (3) A claim now says whether its copy is recording (`recording`, glasses 6.9.510+). A recording claimant takes the ring from an owner that is not recording, and an owner's last `recording: true` holds the ring while it keeps claiming (45 s). Chunk arrival alone lags: after an outage the uploader backs off up to 60 s, and only a visible page resets it, which a hidden recording copy never gets. QA round 3 found that gap: a reopened copy that reclaimed offline took the ring back on reconnect, and the recording copy was then told to stop. Untagged chunks (glasses 6.9.507/6.9.508) count for the owner only while it still claims (45 s), so a dead owner is never pinned by them or by a stale flag. When both copies record, the owner keeps the ring and the claimant stops. The answer's `captureLive` means the owner is recording; glasses 6.9.510 refuse a yield while recording when it is `false`. `lastRecordingChunkAt` (first draft) is gone.
|
|
4
|
+
- **One owner per device.** Owners are kept by the request's address (`::ffff:` mapped addresses folded), up to 16 devices, so an Even simulator on the Mac or a second phone on the same token cannot take the ring from the glasses.
|
|
5
|
+
- **A boot time from the future is refused** (400) past an hour ahead of the server clock: a phone clock set ahead would otherwise outrank every later copy.
|
|
6
|
+
- Tests: the rules executed. The owner's chunks hold however quiet its claims, and release after. A recording claimant takes from a dead owner and from a newer owner that is not recording. With both recording, the owner keeps the ring. Untagged chunks hold only up to the owner's 45 s edge. The ledger's window edges, per-device untagged tracking, and bound are covered, and so are what counts as a chunk and what is a valid tag. The QA round 3 replay: a recording copy's own word takes the ring back from a fresher copy that is not recording, then holds it until 45 s after its last claim. The flag follows the latest claim, and only a literal `true` counts. Over HTTP, stub chunk routes are mounted behind the referee as in `index.ts`: the owner's tagged chunk holds; a recording claim takes the ring back and the other copy then gets `captureLive: true`; the QA round-2 replay (dead owner, a new copy's own chunk first) gives that copy the ring; a tagged iPhone offline replay holds; a preview does not; untagged chunks hold, then release past 45 s; a dual-stack server treats `127.0.0.1` and `::1` as two devices; a future boot is a 400. The mount test pins the referee before `transcribeStreamRouter`. Gate: 18/18 referee cases, including `referee-claimed-recording-ignored`, `referee-recording-flag-never-refreshed`, `referee-recording-parsed-loosely`, `referee-owner-recording-flag-ignored`, `referee-stops-the-only-recorder`, `referee-duplicate-claimant-wins`, `referee-dead-owner-pinned-by-untagged`, `referee-untagged-ignored`, `referee-any-copy-chunk-is-the-owners`, `referee-preview-is-a-chunk`, `referee-chunks-unnoted`.
|
|
7
|
+
|
|
1
8
|
## 6.50.3
|
|
2
9
|
|
|
3
10
|
- **The server referees between copies of the COS Glasses app.** 2026-09-18 10:06: the Even Realities app kept an older WebView copy of the plugin alive (hidden since at least 09:30) behind the one Miles opened, both answered one Start Meeting to the millisecond, and the same 90-minute meeting was recorded and HQ-polished twice. Glasses 6.9.505's claim in the Even app's storage could not stop it: the new copy read the old copy's claim 36 minutes stale, and the hidden copy never refreshed its claim or read the new one, while its HTTP calls to this server kept working (it uploaded all 827 chunks). `POST /api/client-instance/claim` `{ id, bootAt, version }` answers `{ verdict: 'owner' | 'yield', owner }`: the newest boot that is still posting owns the ring (ties by id); a claim from an older boot takes over only after the owner has been quiet for 45 s (`CLIENT_INSTANCE_LIVE_MS`, three 15 s ticks). `check: true` answers without taking, so a copy that already yielded can ask whether the owner is gone without stealing the ring after a phone lock froze both copies' timers. Behind the API token. In memory: a restart forgets the owner and the next posts re-establish it within a tick. Glasses 6.9.507 asks this route first and falls back to the storage claim when the Mac is unreachable or older.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.50.
|
|
3
|
+
"version": "6.50.4",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -532,6 +532,7 @@ const forkThreadForRoute = (request: {
|
|
|
532
532
|
app.use('/api', healthRouter)
|
|
533
533
|
app.use('/api', diagRouter)
|
|
534
534
|
// 6.50.3: the referee between copies of the COS Glasses app (routes/client-instance.ts).
|
|
535
|
+
// Mounted BEFORE transcribeStreamRouter: it notes each meeting chunk on its way past.
|
|
535
536
|
app.use('/api', createClientInstanceRouter())
|
|
536
537
|
app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
537
538
|
prepareAdmission: preparePublicDurableQueryAdmission,
|
|
@@ -16,11 +16,26 @@
|
|
|
16
16
|
export const CLIENT_INSTANCE_TICK_MS = 15_000
|
|
17
17
|
/** Three missed ticks: the owner is gone. */
|
|
18
18
|
export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
|
|
19
|
+
/**
|
|
20
|
+
* 6.50.4: a meeting chunk inside this window means that copy is recording. Five chunk
|
|
21
|
+
* intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
|
|
22
|
+
*/
|
|
23
|
+
export const CLIENT_INSTANCE_CAPTURE_LIVE_MS = 30_000
|
|
24
|
+
/** App copies whose newest chunk is remembered; the least recent is dropped past this. */
|
|
25
|
+
export const CLIENT_INSTANCE_MAX_TAGGED = 64
|
|
26
|
+
/** A boot time this far past the server clock is a phone clock set wrong, not a newer copy. */
|
|
27
|
+
export const CLIENT_INSTANCE_MAX_FUTURE_BOOT_MS = 60 * 60_000
|
|
19
28
|
|
|
20
29
|
export interface ClientInstanceClaim {
|
|
21
30
|
id: string
|
|
22
31
|
bootAt: number
|
|
23
32
|
version: string
|
|
33
|
+
/**
|
|
34
|
+
* The copy says it is recording (glasses 6.9.509+; false when absent). Chunk arrival
|
|
35
|
+
* alone lags: after an outage the uploader backs off up to 60 s, and only a visible page
|
|
36
|
+
* or a display reconnect resets it, which a hidden recording copy never gets (QA round 3).
|
|
37
|
+
*/
|
|
38
|
+
recording: boolean
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
export interface ClientInstanceOwner extends ClientInstanceClaim {
|
|
@@ -31,14 +46,74 @@ export type ClientInstanceVerdict = 'owner' | 'yield'
|
|
|
31
46
|
|
|
32
47
|
const ID_PATTERN = /^[a-z0-9]{1,16}-[a-z0-9]{1,8}$/
|
|
33
48
|
|
|
49
|
+
/** The `clientInstance` a chunk POST names (glasses 6.9.509+), or null. Never throws. */
|
|
50
|
+
export function parseInstanceTag(value: unknown): string | null {
|
|
51
|
+
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What the chunk stream says about who is recording, for one claim from one device.
|
|
56
|
+
* `owner` / `claimant`: that copy's OWN tagged chunks arrived inside
|
|
57
|
+
* CLIENT_INSTANCE_CAPTURE_LIVE_MS. `untagged`: chunks without a tag (glasses before 6.9.509)
|
|
58
|
+
* arrived from this device, so they could be anyone's.
|
|
59
|
+
*/
|
|
60
|
+
export interface CaptureEvidence {
|
|
61
|
+
owner: boolean
|
|
62
|
+
claimant: boolean
|
|
63
|
+
untagged: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const NO_CAPTURE: CaptureEvidence = { owner: false, claimant: false, untagged: false }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Newest chunk arrival per tagged app copy, and per device for untagged chunks. The clock
|
|
70
|
+
* is passed in. Bounded: a copy that stopped sending is forgotten once 64 newer ones have.
|
|
71
|
+
*/
|
|
72
|
+
export class ClientChunkLedger {
|
|
73
|
+
private readonly tagged = new Map<string, number>()
|
|
74
|
+
private readonly untagged = new Map<string, number>()
|
|
75
|
+
|
|
76
|
+
note(device: string, instance: string | null, at: number): void {
|
|
77
|
+
const map = instance ? this.tagged : this.untagged
|
|
78
|
+
const key = instance ?? device
|
|
79
|
+
map.delete(key)
|
|
80
|
+
map.set(key, at)
|
|
81
|
+
while (map.size > CLIENT_INSTANCE_MAX_TAGGED) map.delete(map.keys().next().value as string)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
evidence(device: string, ownerId: string, claimId: string, now: number): CaptureEvidence {
|
|
85
|
+
const live = (at: number | undefined) => at !== undefined && now - at < CLIENT_INSTANCE_CAPTURE_LIVE_MS
|
|
86
|
+
return {
|
|
87
|
+
owner: live(this.tagged.get(ownerId)),
|
|
88
|
+
claimant: live(this.tagged.get(claimId)),
|
|
89
|
+
untagged: live(this.untagged.get(device)),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether the owner is recording. Its own tagged chunks are proof however quiet its claims
|
|
96
|
+
* (a hidden or locked WebView throttles its timers; chunk uploads follow the audio). Its
|
|
97
|
+
* own last claim saying `recording`, and untagged chunks from the device (which could be
|
|
98
|
+
* anyone's), count only while it is still claiming: a dead owner must never be pinned by
|
|
99
|
+
* a stale flag or by the chunks of the copy that replaced it (QA round 2).
|
|
100
|
+
*/
|
|
101
|
+
export function ownerIsRecording(owner: ClientInstanceOwner, evidence: CaptureEvidence, now: number): boolean {
|
|
102
|
+
if (evidence.owner) return true
|
|
103
|
+
return (owner.recording || evidence.untagged) && now - owner.seenAt <= CLIENT_INSTANCE_LIVE_MS
|
|
104
|
+
}
|
|
105
|
+
|
|
34
106
|
/** A body the route may act on, or null. Never throws. */
|
|
35
|
-
export function parseClientInstanceClaim(body: unknown): ClientInstanceClaim | null {
|
|
107
|
+
export function parseClientInstanceClaim(body: unknown, now = Date.now()): ClientInstanceClaim | null {
|
|
36
108
|
if (!body || typeof body !== 'object') return null
|
|
37
109
|
const o = body as Record<string, unknown>
|
|
38
110
|
if (typeof o.id !== 'string' || !ID_PATTERN.test(o.id)) return null
|
|
39
111
|
if (typeof o.bootAt !== 'number' || !Number.isFinite(o.bootAt) || o.bootAt <= 0) return null
|
|
112
|
+
// A copy that booted with the phone clock set ahead would outrank every later copy for
|
|
113
|
+
// as long as the clock stayed wrong (QA, 6.50.3).
|
|
114
|
+
if (o.bootAt > now + CLIENT_INSTANCE_MAX_FUTURE_BOOT_MS) return null
|
|
40
115
|
const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
|
|
41
|
-
return { id: o.id, bootAt: o.bootAt, version }
|
|
116
|
+
return { id: o.id, bootAt: o.bootAt, version, recording: o.recording === true }
|
|
42
117
|
}
|
|
43
118
|
|
|
44
119
|
function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
|
|
@@ -50,10 +125,22 @@ export function arbitrateClientInstance(
|
|
|
50
125
|
owner: ClientInstanceOwner | null,
|
|
51
126
|
claim: ClientInstanceClaim,
|
|
52
127
|
now: number,
|
|
128
|
+
evidence: CaptureEvidence = NO_CAPTURE,
|
|
53
129
|
): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
|
|
54
130
|
const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
|
|
55
131
|
if (!owner) return { owner: fresh, verdict: 'owner', took: true }
|
|
56
|
-
if (owner.id === claim.id) return { owner: { ...owner, seenAt: now }, verdict: 'owner', took: false }
|
|
132
|
+
if (owner.id === claim.id) return { owner: { ...owner, seenAt: now, recording: claim.recording }, verdict: 'owner', took: false }
|
|
133
|
+
// 6.50.4: the yielding copy stops its recording (that is how a duplicate ends), so the
|
|
134
|
+
// ring must never move in a way that stops the only recording.
|
|
135
|
+
// (1) The claimant is recording and the owner is not: the claimant is the meeting.
|
|
136
|
+
// Reopened offline, the Mac still names the dead copy from before; when signal returns,
|
|
137
|
+
// the new copy's chunks land first and its claim must not be told to stop (QA round 2).
|
|
138
|
+
// Its chunks or its own word: after an outage the uploads lag the claims (QA round 3).
|
|
139
|
+
if ((evidence.claimant || claim.recording) && !ownerIsRecording(owner, evidence, now)) return { owner: fresh, verdict: 'owner', took: true }
|
|
140
|
+
// (2) The owner is recording: a newer copy waits, however quiet the owner's claims.
|
|
141
|
+
// Reopening COS on a blank phone page mid-meeting ended the meeting on 6.50.3 (QA).
|
|
142
|
+
// The ring moves on the newer copy's first claim after the meeting stops.
|
|
143
|
+
if (ownerIsRecording(owner, evidence, now)) return { owner, verdict: 'yield', took: false }
|
|
57
144
|
if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
|
|
58
145
|
if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
|
|
59
146
|
return { owner, verdict: 'yield', took: false }
|
|
@@ -6,29 +6,77 @@
|
|
|
6
6
|
// is still there, and must not steal the ring by asking (after a phone lock both copies'
|
|
7
7
|
// timers were frozen, so the owner only LOOKS quiet; the older copy's first tick would
|
|
8
8
|
// otherwise take it).
|
|
9
|
+
//
|
|
10
|
+
// 6.50.4: one owner PER DEVICE (the request's address), so an Even simulator on this Mac or
|
|
11
|
+
// a second phone on the same token cannot take the ring from the glasses (QA, 6.50.3); and
|
|
12
|
+
// the ring never moves in a way that stops the only recording. The router notes every
|
|
13
|
+
// meeting-chunk POST as it passes (it is mounted BEFORE the transcribe-stream router, and
|
|
14
|
+
// only notes, never answers): whose chunk it was (`?clientInstance=`, glasses 6.9.509+) or,
|
|
15
|
+
// untagged, which device sent it. `captureLive` in the answer: the owner is recording.
|
|
9
16
|
|
|
10
17
|
import { Router } from 'express'
|
|
11
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
arbitrateClientInstance,
|
|
20
|
+
ownerIsRecording,
|
|
21
|
+
parseClientInstanceClaim,
|
|
22
|
+
parseInstanceTag,
|
|
23
|
+
ClientChunkLedger,
|
|
24
|
+
NO_CAPTURE,
|
|
25
|
+
type ClientInstanceOwner,
|
|
26
|
+
} from '../lib/client-instance-claim.js'
|
|
27
|
+
|
|
28
|
+
/** Owners kept at once; the least recently seen device is dropped past this. */
|
|
29
|
+
export const CLIENT_INSTANCE_MAX_DEVICES = 16
|
|
12
30
|
|
|
13
|
-
export
|
|
31
|
+
export interface ClientInstanceRouterDeps {
|
|
32
|
+
now?: () => number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A POST that carries a meeting chunk: a live one, or an iPhone offline chunk replayed. The preview is not one. */
|
|
36
|
+
export function isMeetingChunkPost(method: string, path: string): boolean {
|
|
37
|
+
return method === 'POST' && (path === '/transcribe-stream' || /^\/transcribe-stream\/offline-sessions\/[^/]+\/chunks$/.test(path))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `::ffff:100.64.1.2` and `100.64.1.2` are one device. */
|
|
41
|
+
export function deviceKey(address: string | undefined): string {
|
|
42
|
+
const a = (address ?? '').trim()
|
|
43
|
+
return a.startsWith('::ffff:') ? a.slice(7) : (a || 'unknown')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (() => number) = {}): Router {
|
|
47
|
+
const opts: ClientInstanceRouterDeps = typeof deps === 'function' ? { now: deps } : deps
|
|
48
|
+
const now = opts.now ?? Date.now
|
|
14
49
|
const router = Router()
|
|
15
|
-
|
|
50
|
+
const owners = new Map<string, ClientInstanceOwner>()
|
|
51
|
+
const chunks = new ClientChunkLedger()
|
|
52
|
+
router.use((req, _res, next) => {
|
|
53
|
+
if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
|
|
54
|
+
next()
|
|
55
|
+
})
|
|
16
56
|
router.post('/client-instance/claim', (req, res) => {
|
|
17
|
-
const
|
|
57
|
+
const at = now()
|
|
58
|
+
const claim = parseClientInstanceClaim(req.body, at)
|
|
18
59
|
if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
|
|
19
|
-
const
|
|
20
|
-
const
|
|
60
|
+
const device = deviceKey(req.socket?.remoteAddress ?? req.ip)
|
|
61
|
+
const previous = owners.get(device) ?? null
|
|
62
|
+
const evidence = previous ? chunks.evidence(device, previous.id, claim.id, at) : NO_CAPTURE
|
|
63
|
+
const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
|
|
64
|
+
const result = arbitrateClientInstance(previous, claim, at, evidence)
|
|
21
65
|
if (req.body?.check === true) {
|
|
22
66
|
const shown = previous ?? result.owner
|
|
23
|
-
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true })
|
|
67
|
+
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })
|
|
24
68
|
}
|
|
25
|
-
|
|
69
|
+
owners.delete(device)
|
|
70
|
+
owners.set(device, result.owner)
|
|
71
|
+
while (owners.size > CLIENT_INSTANCE_MAX_DEVICES) owners.delete(owners.keys().next().value as string)
|
|
72
|
+
const owner = result.owner
|
|
26
73
|
if (result.took && previous && previous.id !== claim.id) {
|
|
27
|
-
console.log(`[client-instance] ${new Date(
|
|
74
|
+
console.log(`[client-instance] ${new Date(at).toISOString()} ${claim.id} (${claim.version}) took the ring from ${previous.id} on ${device}`)
|
|
28
75
|
}
|
|
29
76
|
res.json({
|
|
30
77
|
verdict: result.verdict,
|
|
31
78
|
owner: { id: owner.id, bootAt: owner.bootAt, version: owner.version, seenAt: owner.seenAt },
|
|
79
|
+
captureLive,
|
|
32
80
|
})
|
|
33
81
|
})
|
|
34
82
|
return router
|