@gotcos/glasses-server 6.50.1 → 6.50.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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 6.50.3
2
+
3
+ - **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.
4
+ - Tests (`client-instance.test.ts`): the arbitration executed (the zombie yields, the owner refreshes, the older reclaims one ms past the live edge, a same-millisecond tie has one owner, malformed claims refused) and over HTTP (state kept across requests, a 400 changes nothing); the mount sits after `requireApiToken` and is not a public path. Gate: `referee-dead-owner-never-released`, `referee-older-boot-wins`, `referee-not-mounted`, 3/3.
5
+
6
+ ## 6.50.2
7
+
8
+ - **The Turbo meeting preview no longer switches itself off for good.** Miles, 2026-09-18 08:43: the `~` provisional line on the lens was gone, Mac-reachable, Turbo configured. The preview route had answered "no text" to 5,719 requests since 2026-09-17 16:00 (in under a millisecond) while the Turbo sidecar itself transcribed the same audio in 0.27 s. One failed request (`The operation was aborted due to timeout`, then `fetch failed`, with the Mac under build load) set `previewAvailable = false`, and the only code that sets it back is the sidecar's own start, which runs once per server boot. Now a failure against a sidecar that is still running is a 10 s cooldown (`PREVIEW_FAILURE_COOLDOWN_MS`) and the next request after it tries again; five failures in a row (`PREVIEW_RECYCLE_AFTER_FAILURES`) recycle the sidecar; a sidecar that exited is restarted from the request path, at most once per 60 s window, doubling to 15 min while restarts keep failing and resetting on any answer. A missing model, a missing binary or a foreign listener on port 8177 is never restarted (a restart cannot fix it). After the server's own shutdown stop, nothing revives the sidecar (a request arriving during shutdown could otherwise have spawned one that outlived the process). Prompt previews were never affected (they fall back to the canonical worker); canonical meeting ASR never was. The preview log lines now carry an ISO timestamp: the lines that recorded this failure had none.
9
+ - Tests (`whisper-preview.test.ts`, executed against spawn and fetch fakes and a fake clock): a timeout is a cooldown and the next request after it gets text; a success resets the count so scattered misses never recycle; five in a row recycle and the new sidecar answers; an exited sidecar restarts from the request path and the backoff doubles across failed restarts; a foreign listener is never re-probed. Eight mutants (latch restored, success never resets, no revive, backoff never doubles, cooldown ignored, foreign port restarted, revive after shutdown, start after shutdown) each turn the suite red; three of them survived the first draft of the tests and the tests were tightened (a recycle's kill is synchronous, its respawn is not, so negatives now wait for the loop to settle).
10
+ - **Coming back to a Desktop tab no longer blocks a Continue for 30 seconds.** Miles, 2026-09-17 07:00: five sends from the glasses refused "Your Mac is writing to this thread right now" until he clicked away from the tab. Claude Desktop had written its away summary (the note it drops when you return to a tab you left) to the transcript 11 s earlier, and the sub-agent that wrote it fired `SubagentStop`. The attach gate reads a transcript written inside 30 s as working unless the hooks vouch that the turn ended, and the vouch required the newest hook event to be the `Stop` itself, so the desk's own bookkeeping after it made the gate strict. The vouch now reads "no turn since the Stop": a `SubagentStop` with no sub-agent open, or a notification, keeps it; anything that opens a turn (a prompt, a tool, a sub-agent starting) still refuses, and a newer Stop still moves the clock.
11
+ - Tests: the two bookkeeping events keep the vouch and each other guard still holds behind them; a tool, a prompt, a sub-agent start and a permission request stay strict. Gate: `turns-b6-desk-bookkeeping-is-strict-again` and `turns-b6-any-event-vouches` added; the four B6 cases killed.
12
+
1
13
  ## 6.50.1
2
14
 
3
15
  What the QA pass on 6.50.0 found in the history it serves (6.50.0 was published before the pass finished; nothing it changed regressed, and a client that does not ask for `?turns` sees no difference).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.50.1",
3
+ "version": "6.50.3",
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
@@ -14,6 +14,7 @@ import { execSync } from 'node:child_process'
14
14
  import { randomBytes } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
+ import { createClientInstanceRouter } from './routes/client-instance.js'
17
18
  import { queryRouter } from './routes/query.js'
18
19
  import { providerProofRouter } from './routes/provider-proof.js'
19
20
  import { transcribeRouter } from './routes/transcribe.js'
@@ -530,6 +531,8 @@ const forkThreadForRoute = (request: {
530
531
  // API routes
531
532
  app.use('/api', healthRouter)
532
533
  app.use('/api', diagRouter)
534
+ // 6.50.3: the referee between copies of the COS Glasses app (routes/client-instance.ts).
535
+ app.use('/api', createClientInstanceRouter())
533
536
  app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
534
537
  prepareAdmission: preparePublicDurableQueryAdmission,
535
538
  }))
@@ -858,7 +861,7 @@ async function gracefulShutdown(): Promise<void> {
858
861
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
859
862
  stopCodexModelCatalogRefresh()
860
863
  stopWhisperServer()
861
- await stopWhisperPreviewServer()
864
+ await stopWhisperPreviewServer({ final: true })
862
865
  stopLocalTtsServer()
863
866
  clearTimeout(forceExit)
864
867
  process.exit(0)
@@ -0,0 +1,60 @@
1
+ // Which copy of the COS Glasses app owns the ring (6.50.3). PURE: the clock is passed in.
2
+ //
3
+ // WHY THE SERVER. On 2026-09-18 the Even Realities app kept an older WebView copy of the
4
+ // plugin alive behind a newly opened one and delivered every ring event to both, so one
5
+ // Start Meeting recorded the same 90 minutes twice. Glasses 6.9.505 put a claim in the Even
6
+ // app's own storage: the newest boot wins, the copy behind it yields. It failed in the field
7
+ // because the hidden copy's storage calls went unanswered (its claim sat 36 min stale and it
8
+ // never read the newer one), while its HTTP calls to this server kept working (it uploaded
9
+ // all 827 chunks). This server is the one party every copy can still reach.
10
+ //
11
+ // RULE. One owner at a time. A claim from the owner refreshes it. A claim from a NEWER boot
12
+ // (ties broken by id) takes ownership. A claim from an older boot takes ownership only when
13
+ // the owner has gone quiet for CLIENT_INSTANCE_LIVE_MS (the newer copy died). In memory: a
14
+ // server restart forgets the owner, and the next posts re-establish it within one tick.
15
+
16
+ export const CLIENT_INSTANCE_TICK_MS = 15_000
17
+ /** Three missed ticks: the owner is gone. */
18
+ export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
19
+
20
+ export interface ClientInstanceClaim {
21
+ id: string
22
+ bootAt: number
23
+ version: string
24
+ }
25
+
26
+ export interface ClientInstanceOwner extends ClientInstanceClaim {
27
+ seenAt: number
28
+ }
29
+
30
+ export type ClientInstanceVerdict = 'owner' | 'yield'
31
+
32
+ const ID_PATTERN = /^[a-z0-9]{1,16}-[a-z0-9]{1,8}$/
33
+
34
+ /** A body the route may act on, or null. Never throws. */
35
+ export function parseClientInstanceClaim(body: unknown): ClientInstanceClaim | null {
36
+ if (!body || typeof body !== 'object') return null
37
+ const o = body as Record<string, unknown>
38
+ if (typeof o.id !== 'string' || !ID_PATTERN.test(o.id)) return null
39
+ if (typeof o.bootAt !== 'number' || !Number.isFinite(o.bootAt) || o.bootAt <= 0) return null
40
+ const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
41
+ return { id: o.id, bootAt: o.bootAt, version }
42
+ }
43
+
44
+ function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
45
+ return a.bootAt > b.bootAt || (a.bootAt === b.bootAt && a.id > b.id)
46
+ }
47
+
48
+ /** The owner after `claim` arrives at `now`, and what to tell its sender. */
49
+ export function arbitrateClientInstance(
50
+ owner: ClientInstanceOwner | null,
51
+ claim: ClientInstanceClaim,
52
+ now: number,
53
+ ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
54
+ const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
55
+ if (!owner) return { owner: fresh, verdict: 'owner', took: true }
56
+ if (owner.id === claim.id) return { owner: { ...owner, seenAt: now }, verdict: 'owner', took: false }
57
+ if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
58
+ if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
59
+ return { owner, verdict: 'yield', took: false }
60
+ }
@@ -792,15 +792,28 @@ export function buildOccupancyProbes(
792
792
  *
793
793
  * `signalFor` is the signal store's reader, taking a full session id or the registry's
794
794
  * 8-character form; `registryIdleAfterStop` reads the registry record for the session.
795
- * The probe answers a Stop time only when BOTH vouch: the session's newest hook event is
796
- * that Stop, the turn is closed, no sub-agent is open, the session has not ended, and
797
- * the registry says idle at or after the Stop (the hooks have returned). Codex never
795
+ * The probe answers a Stop time only when BOTH vouch: nothing since the Stop has opened
796
+ * a turn (the desk's own bookkeeping after it does not count: the sub-agent that writes
797
+ * Claude Desktop's away summary, an idle notification), the turn is closed, no sub-agent
798
+ * is open, the session has not ended, and the registry says idle at or after the Stop
799
+ * (the hooks have returned). Codex never
798
800
  * asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
799
801
  * must not read a Claude signal); `readHolderActivity` guards the provider too.
800
802
  *
801
803
  * Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
802
804
  * gate is byte-for-byte 6.48.0.
803
805
  */
806
+ /**
807
+ * Hook events that can follow a Stop without a turn having started (6.50.2). Miles,
808
+ * 2026-09-17 07:00: five sends refused `native_thread_working` because Claude Desktop
809
+ * wrote its `away_summary` system row to the transcript 11 s earlier and the sub-agent
810
+ * that wrote it fired SubagentStop, so the newest event was not 'Stop' and the 30 s
811
+ * transcript window held. Every event that DOES mean a turn sets `turnOpen`
812
+ * (UserPromptSubmit, a tool) or `subagentsOpen` (SubagentStart), which the guards
813
+ * behind this set still read; a newer Stop replaces `stopAt`.
814
+ */
815
+ const AFTER_THE_TURN: ReadonlySet<string> = new Set(['Stop', 'SubagentStop', 'Notification'])
816
+
804
817
  export function withHookTurnClock(
805
818
  probes: OccupancyProbes,
806
819
  signalFor: (sessionId: string) => HookTurnSignal | undefined,
@@ -812,7 +825,7 @@ export function withHookTurnClock(
812
825
  if (provider !== 'claude') return null
813
826
  if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
814
827
  const signal = signalFor(threadId)
815
- if (!signal || signal.lastEvent !== 'Stop' || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
828
+ if (!signal || !AFTER_THE_TURN.has(signal.lastEvent) || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
816
829
  if (typeof signal.stopAt !== 'number') return null
817
830
  // The engine's own end of turn: the registry flipped idle AFTER this Stop, which
818
831
  // happens only once every Stop hook has returned. Anything else is strict.
@@ -69,6 +69,82 @@ let previewFailure: WhisperPreviewReason = null
69
69
  let previewWorkerModel: WhisperPreviewSidecarModel | null = null
70
70
  let warnedInvalidChoice = false
71
71
 
72
+ /**
73
+ * 6.50.2: a request that fails against a sidecar that is STILL RUNNING is a miss, not a
74
+ * death. Before this, one timeout (the Mac under load) or one refused fetch set
75
+ * `previewAvailable = false`, and its only writer back to true is the sidecar's own start,
76
+ * which runs once per server boot, so the meeting preview lane stayed silent for the rest
77
+ * of the process: 2026-09-17 16:00 to 2026-09-18 08:48 (34 h), 5,719 requests answered 204
78
+ * in under a millisecond while Turbo itself answered the same audio in 0.27 s.
79
+ *
80
+ * Now: a failure against a live sidecar starts a short cooldown and the next request after
81
+ * it tries again. PREVIEW_RECYCLE_AFTER_FAILURES in a row recycles the sidecar (stop, then
82
+ * start). A sidecar that is gone is restarted from the request path, backing off from
83
+ * PREVIEW_RESTART_BASE_MS to PREVIEW_RESTART_MAX_MS so a model that cannot load is not
84
+ * reloaded every minute. Any successful answer resets both counters.
85
+ */
86
+ export const PREVIEW_FAILURE_COOLDOWN_MS = 10_000
87
+ export const PREVIEW_RECYCLE_AFTER_FAILURES = 5
88
+ export const PREVIEW_RESTART_BASE_MS = 60_000
89
+ export const PREVIEW_RESTART_MAX_MS = 15 * 60_000
90
+ let previewCooldownUntil = 0
91
+ let previewConsecutiveFailures = 0
92
+ let previewLastRestartAt = 0
93
+ let previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
94
+ /** Set by the server's shutdown: no request may revive the sidecar after it (QA, 6.50.2). */
95
+ let previewShutdown = false
96
+
97
+ function previewSidecarAlive(): boolean {
98
+ const child = previewProcess
99
+ return !!child && child.exitCode === null && child.signalCode === null
100
+ }
101
+
102
+ function notePreviewRequestSuccess(): void {
103
+ previewConsecutiveFailures = 0
104
+ previewCooldownUntil = 0
105
+ previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
106
+ }
107
+
108
+ /** A request to the sidecar failed (not a preemption). Cooldown, recycle, or mark it gone. */
109
+ function notePreviewRequestFailure(error: unknown, label: string): void {
110
+ previewConsecutiveFailures++
111
+ const message = error instanceof Error ? error.message : String(error)
112
+ const at = new Date().toISOString()
113
+ if (previewSidecarAlive() && previewConsecutiveFailures < PREVIEW_RECYCLE_AFTER_FAILURES) {
114
+ previewCooldownUntil = Date.now() + PREVIEW_FAILURE_COOLDOWN_MS
115
+ console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); next try in ${PREVIEW_FAILURE_COOLDOWN_MS / 1000} s: ${message}`)
116
+ return
117
+ }
118
+ const alive = previewSidecarAlive()
119
+ previewAvailable = false
120
+ previewWorkerModel = null
121
+ previewFailure = 'preview_sidecar_unavailable'
122
+ console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); ${alive ? 'recycling the sidecar' : 'the sidecar is gone'}: ${message}`)
123
+ maybeRevivePreviewSidecar()
124
+ }
125
+
126
+ /**
127
+ * Bring a failed sidecar back, from the request path. Only for a sidecar that ran and then
128
+ * failed or exited: a missing model, a missing binary or a foreign listener on the port
129
+ * cannot be fixed by a restart and stay as they are.
130
+ */
131
+ function maybeRevivePreviewSidecar(): void {
132
+ if (previewShutdown || previewAvailable || previewStarting) return
133
+ if (previewFailure !== 'preview_sidecar_unavailable' && previewFailure !== 'preview_start_failed') return
134
+ const now = Date.now()
135
+ if (previewLastRestartAt && now - previewLastRestartAt < previewRestartBackoffMs) return
136
+ if (previewLastRestartAt) previewRestartBackoffMs = Math.min(previewRestartBackoffMs * 2, PREVIEW_RESTART_MAX_MS)
137
+ previewLastRestartAt = now
138
+ previewConsecutiveFailures = 0
139
+ console.log(`[whisper-preview] ${new Date(now).toISOString()} restarting the preview sidecar (${previewFailure}); next restart no sooner than ${Math.round(previewRestartBackoffMs / 1000)} s`)
140
+ void (async () => {
141
+ if (previewProcess) await stopWhisperPreviewServer()
142
+ await startWhisperPreviewServer()
143
+ })().catch(error => {
144
+ console.warn(`[whisper-preview] restart failed: ${error instanceof Error ? error.message : error}`)
145
+ })
146
+ }
147
+
72
148
  interface ProcessEntry {
73
149
  pid: number
74
150
  ppid: number
@@ -260,6 +336,9 @@ async function endpointReady(path: '/health' | '/inference', init?: RequestInit,
260
336
  * uses Turbo while its canonical live worker remains Large-v3. Failure is
261
337
  * cosmetic: every recovery/finalization path stays untouched. */
262
338
  export async function startWhisperPreviewServer(): Promise<void> {
339
+ // A revive already past its stop when shutdown landed must not spawn an orphan that
340
+ // outlives the server.
341
+ if (previewShutdown) return
263
342
  const requested = requestedPreviewModel()
264
343
  const selected = selectedPreviewModel(requested)
265
344
  if (!needsPreviewSidecar(selected) || previewProcess || previewAvailable || previewStarting) return
@@ -359,7 +438,8 @@ function waitForPreviewClose(child: ChildProcess, timeoutMs: number): Promise<bo
359
438
  })
360
439
  }
361
440
 
362
- export async function stopWhisperPreviewServer(): Promise<void> {
441
+ export async function stopWhisperPreviewServer(options: { final?: boolean } = {}): Promise<void> {
442
+ if (options.final) previewShutdown = true
363
443
  const child = previewProcess
364
444
  previewProcess = null
365
445
  previewAvailable = false
@@ -395,27 +475,23 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
395
475
  model: 'small.en' | WhisperCommitModel
396
476
  backend: 'whisper-preview-server' | 'whisper-server'
397
477
  }> {
398
- if (previewAvailable && previewWorkerModel) {
478
+ if (!previewAvailable) maybeRevivePreviewSidecar()
479
+ if (previewAvailable && previewWorkerModel && Date.now() >= previewCooldownUntil) {
399
480
  const workerModel = previewWorkerModel
400
481
  const previewLease = tryAcquireMetalPreview()
401
482
  if (!previewLease) {
402
483
  return { text: '', model: workerModel, backend: 'whisper-preview-server' }
403
484
  }
404
485
  try {
405
- return {
406
- text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
407
- model: workerModel,
408
- backend: 'whisper-preview-server',
409
- }
486
+ const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
487
+ notePreviewRequestSuccess()
488
+ return { text, model: workerModel, backend: 'whisper-preview-server' }
410
489
  } catch (error) {
411
490
  if (previewLease.signal.aborted) {
412
491
  return { text: '', model: workerModel, backend: 'whisper-preview-server' }
413
492
  }
414
- const failedModel = previewWorkerModel
415
- previewAvailable = false
416
- previewWorkerModel = null
417
- previewFailure = 'preview_sidecar_unavailable'
418
- console.warn(`[whisper-preview] ${failedModel} preview failed; falling back to canonical worker: ${error instanceof Error ? error.message : error}`)
493
+ // Falls through to the canonical worker below for THIS request either way.
494
+ notePreviewRequestFailure(error, `${workerModel} preview`)
419
495
  } finally {
420
496
  previewLease.release()
421
497
  }
@@ -448,21 +524,20 @@ export async function transcribeWhisperMeetingPreview(audioBuffer: Buffer): Prom
448
524
  model: 'large-v3-turbo'
449
525
  backend: 'whisper-preview-server'
450
526
  } | null> {
527
+ if (!previewAvailable) maybeRevivePreviewSidecar()
451
528
  if (!previewAvailable || previewWorkerModel !== 'large-v3-turbo') return null
529
+ // A cooldown after a failed request is a quiet miss, never a latch (see above).
530
+ if (Date.now() < previewCooldownUntil) return null
452
531
  const previewLease = tryAcquireMetalPreview()
453
532
  if (!previewLease) return null
454
533
  try {
455
- return {
456
- text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
457
- model: 'large-v3-turbo',
458
- backend: 'whisper-preview-server',
459
- }
534
+ const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
535
+ notePreviewRequestSuccess()
536
+ return { text, model: 'large-v3-turbo', backend: 'whisper-preview-server' }
460
537
  } catch (error) {
461
538
  if (previewLease.signal.aborted) return null
462
- previewAvailable = false
463
- previewWorkerModel = null
464
- previewFailure = 'preview_sidecar_unavailable'
465
- console.warn(`[whisper-preview] meeting Turbo preview failed; canonical meeting ASR was not affected: ${error instanceof Error ? error.message : error}`)
539
+ // Canonical meeting ASR is not affected by any of this: the preview is cosmetic.
540
+ notePreviewRequestFailure(error, 'meeting Turbo preview')
466
541
  return null
467
542
  } finally {
468
543
  previewLease.release()
@@ -0,0 +1,35 @@
1
+ // POST /api/client-instance/claim — the referee between copies of the COS Glasses app (6.50.3).
2
+ // See lib/client-instance-claim.ts for the why. Behind the global API token like every /api
3
+ // route except health and diag. Body: { id, bootAt, version, check? }. Answer: { verdict, owner }.
4
+ //
5
+ // `check: true` answers without taking: a copy that already yielded asks whether the owner
6
+ // is still there, and must not steal the ring by asking (after a phone lock both copies'
7
+ // timers were frozen, so the owner only LOOKS quiet; the older copy's first tick would
8
+ // otherwise take it).
9
+
10
+ import { Router } from 'express'
11
+ import { arbitrateClientInstance, parseClientInstanceClaim, type ClientInstanceOwner } from '../lib/client-instance-claim.js'
12
+
13
+ export function createClientInstanceRouter(now: () => number = Date.now): Router {
14
+ const router = Router()
15
+ let owner: ClientInstanceOwner | null = null
16
+ router.post('/client-instance/claim', (req, res) => {
17
+ const claim = parseClientInstanceClaim(req.body)
18
+ if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
19
+ const previous = owner
20
+ const result = arbitrateClientInstance(owner, claim, now())
21
+ if (req.body?.check === true) {
22
+ 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 })
24
+ }
25
+ owner = result.owner
26
+ if (result.took && previous && previous.id !== claim.id) {
27
+ console.log(`[client-instance] ${new Date(now()).toISOString()} ${claim.id} (${claim.version}) took the ring from ${previous.id}`)
28
+ }
29
+ res.json({
30
+ verdict: result.verdict,
31
+ owner: { id: owner.id, bootAt: owner.bootAt, version: owner.version, seenAt: owner.seenAt },
32
+ })
33
+ })
34
+ return router
35
+ }