@gotcos/glasses-server 6.88.0 → 6.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/CHANGELOG.md +67 -0
- package/SECURITY.md +5 -10
- package/docs/tailscale-self-heal.md +159 -0
- package/managed-runtime-contract.json +2 -0
- package/package.json +4 -3
- package/server/index.ts +21 -12
- package/server/lib/api-auth.ts +0 -7
- package/server/lib/mac-voice-labels.ts +50 -0
- package/server/lib/meeting-batch-transcribe.ts +9 -3
- package/server/lib/meeting-relabel-enrolment.ts +12 -1
- package/server/lib/meeting-relabel.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +4 -1
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-final.ts +314 -7
- package/server/lib/nemotron-live.ts +3 -1
- package/server/lib/speaker-embeddings.ts +39 -1
- package/server/lib/tailscale-nudge.ts +540 -0
- package/server/routes/health.ts +3 -15
- package/server/routes/transcribe-stream.ts +13 -2
- package/docs/lan-first.md +0 -158
- package/server/lib/lan-first.ts +0 -236
- package/server/routes/route-proof.ts +0 -83
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// 6.89.0 Tailscale self-heal: the Mac-side "nudge" (docs/tailscale-self-heal.md).
|
|
2
|
+
//
|
|
3
|
+
// Why: on 2026-10-22 the Mac restarted at 14:09 and the server answered on every address from
|
|
4
|
+
// 14:10:58, but the iPhone's Tailscale extension kept a stale tunnel. Tailscale's own ping got
|
|
5
|
+
// through, no COS request did, and only toggling Tailscale on the phone (15:09) fixed it. The goal
|
|
6
|
+
// is that a Mac restart, a wake from sleep, a Tailscale restart on the Mac or a network change heals
|
|
7
|
+
// without anyone touching Tailscale.
|
|
8
|
+
//
|
|
9
|
+
// What a nudge is: two bounded `tailscale ping`s at one peer, run with execFile from a fixed path,
|
|
10
|
+
// never a shell:
|
|
11
|
+
// 1. `ping -c 3 --timeout 5s <ip>`: a disco ping. It makes both ends redo path discovery, and it
|
|
12
|
+
// stops early once a direct path answers.
|
|
13
|
+
// 2. only after a pong, `ping --icmp -c 1 --timeout 5s <ip>`: a packet THROUGH WireGuard. The disco
|
|
14
|
+
// ping alone can succeed while the data path is stale (it does not use the tunnel, which is what
|
|
15
|
+
// 10/22 looked like); a data packet makes WireGuard start a fresh handshake when its session is
|
|
16
|
+
// stale.
|
|
17
|
+
// Whether this un-sticks the iOS extension in the field is NOT proven here; the field canary is.
|
|
18
|
+
//
|
|
19
|
+
// When (triggers), each needing the POSITIVE signal "tailscale status says this peer is Online"
|
|
20
|
+
// from a status read at most STATUS_FRESH_MS old. Absence of requests alone never nudges.
|
|
21
|
+
// boot 30 s after the server starts.
|
|
22
|
+
// wake the wall clock moved 20 s+ more than the monotonic clock (the Mac slept).
|
|
23
|
+
// network this Mac's IPv4 interfaces changed (Tailscale's utun included).
|
|
24
|
+
// tailscale Tailscale's own state changed: BackendState, this Mac's Tailscale IPs, or the peer
|
|
25
|
+
// byte counters went backwards (tailscaled restarted).
|
|
26
|
+
// silent a phone that made an authenticated request in the last 5 minutes, or sent meeting
|
|
27
|
+
// chunks in the last 15 minutes while a meeting is open, has made no request at all
|
|
28
|
+
// for 45 s, while its peer is Online.
|
|
29
|
+
// Event triggers (boot, wake, network, tailscale) aim at peers this process has seen authenticate in
|
|
30
|
+
// the last 12 hours, plus Online iOS and Android peers (after a restart the Mac remembers no one),
|
|
31
|
+
// at most 4, and skip any peer that has already made a request since the event.
|
|
32
|
+
//
|
|
33
|
+
// Caps: one nudge per peer per 60 s, doubling per nudge that brings no request (60, 120, 240, 480,
|
|
34
|
+
// then 900 s), reset by any request from that peer; 30 nudges an hour in all; one at a time.
|
|
35
|
+
// COS_TAILSCALE_NUDGE=0 (or false/off) stops everything, read every tick, no restart needed.
|
|
36
|
+
// No LLM, no disk write, no shell. Logs carry trigger, outcome and timing only, never an address.
|
|
37
|
+
|
|
38
|
+
import { execFile } from 'node:child_process'
|
|
39
|
+
import { accessSync, constants as fsConstants } from 'node:fs'
|
|
40
|
+
import { networkInterfaces } from 'node:os'
|
|
41
|
+
import { readSwitch } from './env-switch.js'
|
|
42
|
+
import { isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
|
|
43
|
+
|
|
44
|
+
export const TAILSCALE_NUDGE_LIMITS = Object.freeze({
|
|
45
|
+
tickMs: 10_000,
|
|
46
|
+
bootDelayMs: 30_000,
|
|
47
|
+
wakeSettleMs: 15_000,
|
|
48
|
+
networkSettleMs: 10_000,
|
|
49
|
+
/** An event trigger that cannot run (Tailscale not Running yet) is dropped after this. */
|
|
50
|
+
eventExpiryMs: 120_000,
|
|
51
|
+
/** The monotonic clock stops while the Mac sleeps; a wall-clock lead past this is a wake. */
|
|
52
|
+
wakeGapMs: 20_000,
|
|
53
|
+
silenceMs: 45_000,
|
|
54
|
+
activeWindowMs: 5 * 60_000,
|
|
55
|
+
meetingWindowMs: 15 * 60_000,
|
|
56
|
+
knownPeerWindowMs: 12 * 60 * 60_000,
|
|
57
|
+
minIntervalMs: 60_000,
|
|
58
|
+
maxBackoffMs: 15 * 60_000,
|
|
59
|
+
maxPerHour: 30,
|
|
60
|
+
eventMaxPeers: 4,
|
|
61
|
+
statusPollMs: 60_000,
|
|
62
|
+
statusFreshMs: 15_000,
|
|
63
|
+
recoveredWithinMs: 90_000,
|
|
64
|
+
discoTimeoutMs: 20_000,
|
|
65
|
+
icmpTimeoutMs: 10_000,
|
|
66
|
+
statusTimeoutMs: 5_000,
|
|
67
|
+
binRecheckMs: 10 * 60_000,
|
|
68
|
+
maxTrackedPeers: 16,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** Fixed places the Tailscale CLI lives. No PATH lookup and no override: the path is never input. */
|
|
72
|
+
export const TAILSCALE_BIN_CANDIDATES = Object.freeze([
|
|
73
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale',
|
|
74
|
+
'/opt/homebrew/bin/tailscale',
|
|
75
|
+
'/usr/local/bin/tailscale',
|
|
76
|
+
'/usr/bin/tailscale',
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
export const DISCO_PING_ARGS = (ip: string): string[] => ['ping', '-c', '3', '--timeout', '5s', ip]
|
|
80
|
+
export const ICMP_PING_ARGS = (ip: string): string[] => ['ping', '--icmp', '-c', '1', '--timeout', '5s', ip]
|
|
81
|
+
export const STATUS_ARGS: readonly string[] = Object.freeze(['status', '--json'])
|
|
82
|
+
|
|
83
|
+
export type NudgeTrigger = 'boot' | 'wake' | 'network' | 'tailscale' | 'silent'
|
|
84
|
+
export type NudgeOutcome = 'pong_direct' | 'pong_relay' | 'no_pong' | 'timeout' | 'error' | 'unavailable'
|
|
85
|
+
export type DataPathOutcome = 'ok' | 'failed' | 'skipped'
|
|
86
|
+
|
|
87
|
+
export interface RunResult {
|
|
88
|
+
code: number | null
|
|
89
|
+
signal: string | null
|
|
90
|
+
killed: boolean
|
|
91
|
+
stdout: string
|
|
92
|
+
stderr: string
|
|
93
|
+
/** Set when the process could not be started at all (ENOENT, EACCES). */
|
|
94
|
+
spawnError?: string
|
|
95
|
+
elapsedMs: number
|
|
96
|
+
}
|
|
97
|
+
/** The one way this module runs Tailscale: a binary path and an argument array, never a shell. */
|
|
98
|
+
export type TailscaleRunner = (bin: string, args: readonly string[], timeoutMs: number) => Promise<RunResult>
|
|
99
|
+
|
|
100
|
+
export const execFileRunner: TailscaleRunner = (bin, args, timeoutMs) => new Promise(resolve => {
|
|
101
|
+
const started = Date.now()
|
|
102
|
+
execFile(bin, [...args], { timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, windowsHide: true, encoding: 'utf8' },
|
|
103
|
+
(error, stdout, stderr) => {
|
|
104
|
+
const err = error as (NodeJS.ErrnoException & { code?: number | string; killed?: boolean; signal?: string }) | null
|
|
105
|
+
const spawnError = err && typeof err.code === 'string' ? err.code : undefined
|
|
106
|
+
resolve({
|
|
107
|
+
code: err ? (typeof err.code === 'number' ? err.code : null) : 0,
|
|
108
|
+
signal: err?.signal ?? null,
|
|
109
|
+
killed: !!err?.killed,
|
|
110
|
+
stdout: String(stdout ?? ''),
|
|
111
|
+
stderr: String(stderr ?? ''),
|
|
112
|
+
...(spawnError ? { spawnError } : {}),
|
|
113
|
+
elapsedMs: Date.now() - started,
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export function resolveTailscaleBin(isExecutable: (p: string) => boolean = defaultIsExecutable): string | null {
|
|
119
|
+
for (const candidate of TAILSCALE_BIN_CANDIDATES) if (isExecutable(candidate)) return candidate
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
function defaultIsExecutable(p: string): boolean {
|
|
123
|
+
try { accessSync(p, fsConstants.X_OK); return true } catch { return false }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface PeerStatus {
|
|
127
|
+
ip: string
|
|
128
|
+
online: boolean
|
|
129
|
+
os: string
|
|
130
|
+
rxBytes: number
|
|
131
|
+
txBytes: number
|
|
132
|
+
/** Epoch ms of the last WireGuard handshake, or 0 for none. */
|
|
133
|
+
lastHandshakeMs: number
|
|
134
|
+
}
|
|
135
|
+
export interface TailscaleStatus {
|
|
136
|
+
backendState: string
|
|
137
|
+
selfIps: string[]
|
|
138
|
+
peers: PeerStatus[]
|
|
139
|
+
at: number
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Parse `tailscale status --json`. Returns null for anything that is not that shape. */
|
|
143
|
+
export function parseTailscaleStatus(text: string, at: number): TailscaleStatus | null {
|
|
144
|
+
let raw: any
|
|
145
|
+
try { raw = JSON.parse(text) } catch { return null }
|
|
146
|
+
if (!raw || typeof raw !== 'object' || typeof raw.BackendState !== 'string') return null
|
|
147
|
+
const v4 = (ips: unknown): string[] => Array.isArray(ips) ? ips.filter((ip): ip is string => typeof ip === 'string' && isTailscaleIpv4(ip)) : []
|
|
148
|
+
const peers: PeerStatus[] = []
|
|
149
|
+
const peerMap = raw.Peer && typeof raw.Peer === 'object' ? raw.Peer : {}
|
|
150
|
+
for (const p of Object.values(peerMap) as any[]) {
|
|
151
|
+
const ip = v4(p?.TailscaleIPs)[0]
|
|
152
|
+
if (!ip) continue
|
|
153
|
+
const hs = typeof p.LastHandshake === 'string' ? Date.parse(p.LastHandshake) : NaN
|
|
154
|
+
peers.push({
|
|
155
|
+
ip,
|
|
156
|
+
online: p.Online === true,
|
|
157
|
+
os: typeof p.OS === 'string' ? p.OS : '',
|
|
158
|
+
rxBytes: Number.isFinite(p.RxBytes) ? p.RxBytes : 0,
|
|
159
|
+
txBytes: Number.isFinite(p.TxBytes) ? p.TxBytes : 0,
|
|
160
|
+
lastHandshakeMs: Number.isFinite(hs) && hs > 0 ? hs : 0,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
return { backendState: raw.BackendState, selfIps: v4(raw.Self?.TailscaleIPs).sort(), peers, at }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Disco ping output: "pong from <name> (<ip>) via <DERP(x) | ip:port | peer-relay(...)> in 91ms". */
|
|
167
|
+
export function classifyDiscoPing(result: RunResult): NudgeOutcome {
|
|
168
|
+
if (result.spawnError) return 'unavailable'
|
|
169
|
+
const pong = /pong from .* via (\S+) in [\d.]+ms/.exec(result.stdout)
|
|
170
|
+
if (pong) return /^(DERP|peer-relay)/i.test(pong[1]) ? 'pong_relay' : 'pong_direct'
|
|
171
|
+
if (result.killed || result.signal) return 'timeout'
|
|
172
|
+
if (/no reply|timed out|timeout/i.test(result.stdout + result.stderr)) return 'no_pong'
|
|
173
|
+
return result.code === 0 ? 'no_pong' : 'error'
|
|
174
|
+
}
|
|
175
|
+
export function classifyIcmpPing(result: RunResult): DataPathOutcome {
|
|
176
|
+
return !result.spawnError && /pong from .* via ICMP/i.test(result.stdout) ? 'ok' : 'failed'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every external IPv4 interface address, sorted: changes when a network or the Tailscale tunnel changes. */
|
|
180
|
+
type InterfaceInfo = { address: string; family: string | number; internal: boolean }
|
|
181
|
+
export function networkFingerprint(nets: Record<string, InterfaceInfo[] | undefined> = networkInterfaces()): string {
|
|
182
|
+
const out: string[] = []
|
|
183
|
+
for (const [name, infos] of Object.entries(nets)) {
|
|
184
|
+
for (const info of infos ?? []) {
|
|
185
|
+
const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : String(info.family)
|
|
186
|
+
if (family === 'IPv4' && !info.internal) out.push(`${name}=${info.address}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out.sort().join(',')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Did Tailscale's own state change between two status reads? A failed read is not a change. */
|
|
193
|
+
export function tailscaleStateChanged(prev: TailscaleStatus | null, next: TailscaleStatus | null): boolean {
|
|
194
|
+
if (!prev || !next) return false
|
|
195
|
+
if (prev.backendState !== next.backendState) return true
|
|
196
|
+
if (prev.selfIps.join(',') !== next.selfIps.join(',')) return true
|
|
197
|
+
// tailscaled keeps per-peer byte counters for its lifetime: a total that went DOWN means it restarted.
|
|
198
|
+
const total = (s: TailscaleStatus) => s.peers.reduce((n, p) => n + p.rxBytes + p.txBytes, 0)
|
|
199
|
+
return total(next) < total(prev)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Seconds between nudges for a peer, given how many nudges in a row brought no request. */
|
|
203
|
+
export function backoffMs(consecutive: number): number {
|
|
204
|
+
if (consecutive <= 0) return 0
|
|
205
|
+
const { minIntervalMs, maxBackoffMs } = TAILSCALE_NUDGE_LIMITS
|
|
206
|
+
return Math.min(minIntervalMs * 2 ** (consecutive - 1), maxBackoffMs)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function isMobileOs(os: string): boolean {
|
|
210
|
+
return /^(ios|android)$/i.test(os.trim())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface PeerTrack {
|
|
214
|
+
/** Any /api request from this address, public routes included: proof the data path works. */
|
|
215
|
+
lastRequestAt: number
|
|
216
|
+
lastAuthenticatedAt: number
|
|
217
|
+
lastMeetingChunkAt: number
|
|
218
|
+
lastNudgeAt: number
|
|
219
|
+
/** Nudges since the last request from this peer. */
|
|
220
|
+
consecutive: number
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface TailscaleNudgeDeps {
|
|
224
|
+
runner?: TailscaleRunner
|
|
225
|
+
now?: () => number
|
|
226
|
+
/** Monotonic milliseconds; it does not advance while the Mac sleeps. */
|
|
227
|
+
monotonic?: () => number
|
|
228
|
+
env?: () => NodeJS.ProcessEnv
|
|
229
|
+
resolveBin?: () => string | null
|
|
230
|
+
fingerprint?: () => string
|
|
231
|
+
activeMeetings?: () => number
|
|
232
|
+
log?: (line: string) => void
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface TailscaleNudgeHealth {
|
|
236
|
+
enabled: boolean
|
|
237
|
+
/** null until the first look for the Tailscale CLI; false when it is not installed. */
|
|
238
|
+
available: boolean | null
|
|
239
|
+
lastAt: string | null
|
|
240
|
+
lastTrigger: NudgeTrigger | null
|
|
241
|
+
lastOutcome: NudgeOutcome | null
|
|
242
|
+
lastDataPath: DataPathOutcome | null
|
|
243
|
+
/** When a nudged peer next made a request, within 90 s of the nudge. */
|
|
244
|
+
lastRecoveredAt: string | null
|
|
245
|
+
nudgesLastHour: number
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface PendingEvent { trigger: Exclude<NudgeTrigger, 'silent'>; firedAt: number; dueAt: number }
|
|
249
|
+
|
|
250
|
+
export class TailscaleNudger {
|
|
251
|
+
private readonly runner: TailscaleRunner
|
|
252
|
+
private readonly now: () => number
|
|
253
|
+
private readonly monotonic: () => number
|
|
254
|
+
private readonly env: () => NodeJS.ProcessEnv
|
|
255
|
+
private readonly resolveBinFn: () => string | null
|
|
256
|
+
private readonly fingerprintFn: () => string
|
|
257
|
+
private readonly activeMeetings: () => number
|
|
258
|
+
private readonly log: (line: string) => void
|
|
259
|
+
|
|
260
|
+
private readonly peers = new Map<string, PeerTrack>()
|
|
261
|
+
private readonly hourLog: number[] = []
|
|
262
|
+
private pending: PendingEvent | null = null
|
|
263
|
+
private bin: string | null | undefined = undefined
|
|
264
|
+
private binCheckedAt = 0
|
|
265
|
+
private status: TailscaleStatus | null = null
|
|
266
|
+
private previousStatus: TailscaleStatus | null = null
|
|
267
|
+
private lastStatusAttemptAt = 0
|
|
268
|
+
private lastFingerprint: string | null = null
|
|
269
|
+
private lastTick: { wall: number; mono: number } | null = null
|
|
270
|
+
private busy = false
|
|
271
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
272
|
+
private lastNudge: { at: number; ip: string; trigger: NudgeTrigger; outcome: NudgeOutcome; dataPath: DataPathOutcome } | null = null
|
|
273
|
+
private lastRecoveredAt: number | null = null
|
|
274
|
+
|
|
275
|
+
constructor(deps: TailscaleNudgeDeps = {}) {
|
|
276
|
+
this.runner = deps.runner ?? execFileRunner
|
|
277
|
+
this.now = deps.now ?? Date.now
|
|
278
|
+
this.monotonic = deps.monotonic ?? (() => Number(process.hrtime.bigint() / 1_000_000n))
|
|
279
|
+
this.env = deps.env ?? (() => process.env)
|
|
280
|
+
this.resolveBinFn = deps.resolveBin ?? (() => resolveTailscaleBin())
|
|
281
|
+
this.fingerprintFn = deps.fingerprint ?? (() => networkFingerprint())
|
|
282
|
+
this.activeMeetings = deps.activeMeetings ?? (() => 0)
|
|
283
|
+
this.log = deps.log ?? (line => console.log(line))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
enabled(): boolean {
|
|
287
|
+
return readSwitch(this.env().COS_TAILSCALE_NUDGE, true)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Start the tick. The boot trigger is due 30 s from now. Timers are unref'd. */
|
|
291
|
+
start(): void {
|
|
292
|
+
if (this.timer) return
|
|
293
|
+
const at = this.now()
|
|
294
|
+
this.pending = { trigger: 'boot', firedAt: at, dueAt: at + TAILSCALE_NUDGE_LIMITS.bootDelayMs }
|
|
295
|
+
this.timer = setInterval(() => { void this.tick() }, TAILSCALE_NUDGE_LIMITS.tickMs)
|
|
296
|
+
this.timer.unref?.()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
stop(): void {
|
|
300
|
+
if (this.timer) clearInterval(this.timer)
|
|
301
|
+
this.timer = null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Called for every /api request (before the token check) with the socket address. Only
|
|
306
|
+
* Tailscale addresses are tracked. Never throws.
|
|
307
|
+
*/
|
|
308
|
+
noteRequest(remoteAddress: string | undefined | null, kind: { authenticated?: boolean; meetingChunk?: boolean } = {}): void {
|
|
309
|
+
try {
|
|
310
|
+
const ip = normalizeRemoteIp(remoteAddress ?? '')
|
|
311
|
+
if (!isTailscaleIpv4(ip)) return
|
|
312
|
+
const at = this.now()
|
|
313
|
+
let track = this.peers.get(ip)
|
|
314
|
+
if (!track) {
|
|
315
|
+
track = { lastRequestAt: 0, lastAuthenticatedAt: 0, lastMeetingChunkAt: 0, lastNudgeAt: 0, consecutive: 0 }
|
|
316
|
+
}
|
|
317
|
+
this.peers.delete(ip)
|
|
318
|
+
this.peers.set(ip, track)
|
|
319
|
+
while (this.peers.size > TAILSCALE_NUDGE_LIMITS.maxTrackedPeers) this.peers.delete(this.peers.keys().next().value as string)
|
|
320
|
+
if (track.consecutive > 0 && this.lastNudge?.ip === ip && at - this.lastNudge.at <= TAILSCALE_NUDGE_LIMITS.recoveredWithinMs
|
|
321
|
+
&& (this.lastRecoveredAt === null || this.lastRecoveredAt < this.lastNudge.at)) {
|
|
322
|
+
this.lastRecoveredAt = at
|
|
323
|
+
this.log(`[tailscale-nudge] peer answered ${((at - this.lastNudge.at) / 1000).toFixed(1)}s after the ${this.lastNudge.trigger} nudge`)
|
|
324
|
+
}
|
|
325
|
+
track.lastRequestAt = at
|
|
326
|
+
track.consecutive = 0
|
|
327
|
+
if (kind.authenticated) track.lastAuthenticatedAt = at
|
|
328
|
+
if (kind.meetingChunk) track.lastMeetingChunkAt = at
|
|
329
|
+
} catch { /* observer only */ }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
health(): TailscaleNudgeHealth {
|
|
333
|
+
const at = this.now()
|
|
334
|
+
return {
|
|
335
|
+
enabled: this.enabled(),
|
|
336
|
+
available: this.bin === undefined ? null : this.bin !== null,
|
|
337
|
+
lastAt: this.lastNudge ? new Date(this.lastNudge.at).toISOString() : null,
|
|
338
|
+
lastTrigger: this.lastNudge?.trigger ?? null,
|
|
339
|
+
lastOutcome: this.lastNudge?.outcome ?? null,
|
|
340
|
+
lastDataPath: this.lastNudge?.dataPath ?? null,
|
|
341
|
+
lastRecoveredAt: this.lastRecoveredAt !== null ? new Date(this.lastRecoveredAt).toISOString() : null,
|
|
342
|
+
nudgesLastHour: this.hourLog.filter(t => at - t < 3_600_000).length,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One decision pass. Public for tests; the interval calls it. Never throws, never overlaps. */
|
|
347
|
+
async tick(): Promise<void> {
|
|
348
|
+
if (this.busy) return
|
|
349
|
+
this.busy = true
|
|
350
|
+
try { await this.tickInner() } catch { /* a failed pass waits for the next tick */ } finally { this.busy = false }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async tickInner(): Promise<void> {
|
|
354
|
+
const at = this.now()
|
|
355
|
+
const mono = this.monotonic()
|
|
356
|
+
const L = TAILSCALE_NUDGE_LIMITS
|
|
357
|
+
// Wake and network detection run even when switched off, so turning it on later starts clean.
|
|
358
|
+
if (this.lastTick && (at - this.lastTick.wall) - (mono - this.lastTick.mono) > L.wakeGapMs) {
|
|
359
|
+
this.raise('wake', at, L.wakeSettleMs)
|
|
360
|
+
}
|
|
361
|
+
this.lastTick = { wall: at, mono }
|
|
362
|
+
const fp = safe(this.fingerprintFn, '')
|
|
363
|
+
if (this.lastFingerprint !== null && fp !== this.lastFingerprint) this.raise('network', at, L.networkSettleMs)
|
|
364
|
+
this.lastFingerprint = fp
|
|
365
|
+
|
|
366
|
+
if (!this.enabled()) return
|
|
367
|
+
if (!this.ensureBin(at)) return
|
|
368
|
+
|
|
369
|
+
if (at - this.lastStatusAttemptAt >= L.statusPollMs) {
|
|
370
|
+
// Compared only on a successful read: a failed one leaves the last good status in place.
|
|
371
|
+
const before = this.status
|
|
372
|
+
const read = await this.readStatus(at)
|
|
373
|
+
if (read && tailscaleStateChanged(before, read)) this.raise('tailscale', at, L.networkSettleMs)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const pending = this.pending
|
|
377
|
+
if (pending && at >= pending.dueAt) {
|
|
378
|
+
await this.runEvent(pending, at)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
await this.runSilent(at)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private raise(trigger: PendingEvent['trigger'], at: number, settleMs: number): void {
|
|
385
|
+
// A boot still waiting keeps its own due time; any later event replaces an earlier one.
|
|
386
|
+
if (this.pending?.trigger === 'boot' && trigger !== 'boot') {
|
|
387
|
+
this.pending = { trigger, firedAt: this.pending.firedAt, dueAt: Math.max(this.pending.dueAt, at + settleMs) }
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
this.pending = { trigger, firedAt: at, dueAt: at + settleMs }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private ensureBin(at: number): boolean {
|
|
394
|
+
if (this.bin === undefined || (this.bin === null && at - this.binCheckedAt >= TAILSCALE_NUDGE_LIMITS.binRecheckMs)) {
|
|
395
|
+
this.bin = safe(this.resolveBinFn, null)
|
|
396
|
+
this.binCheckedAt = at
|
|
397
|
+
}
|
|
398
|
+
return this.bin !== null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async readStatus(at: number): Promise<TailscaleStatus | null> {
|
|
402
|
+
this.lastStatusAttemptAt = at
|
|
403
|
+
const result = await this.runner(this.bin as string, STATUS_ARGS, TAILSCALE_NUDGE_LIMITS.statusTimeoutMs)
|
|
404
|
+
if (result.spawnError === 'ENOENT') this.bin = null
|
|
405
|
+
const parsed = result.spawnError ? null : parseTailscaleStatus(result.stdout, at)
|
|
406
|
+
if (parsed) {
|
|
407
|
+
this.previousStatus = this.status
|
|
408
|
+
this.status = parsed
|
|
409
|
+
}
|
|
410
|
+
return parsed
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A status no older than STATUS_FRESH_MS, read now if needed; null when Tailscale cannot say. */
|
|
414
|
+
private async freshStatus(at: number): Promise<TailscaleStatus | null> {
|
|
415
|
+
if (this.status && at - this.status.at <= TAILSCALE_NUDGE_LIMITS.statusFreshMs) return this.status
|
|
416
|
+
const before = this.status
|
|
417
|
+
const read = await this.readStatus(at)
|
|
418
|
+
if (read && tailscaleStateChanged(before, read)) this.raise('tailscale', at, TAILSCALE_NUDGE_LIMITS.networkSettleMs)
|
|
419
|
+
return read
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private async runEvent(pending: PendingEvent, at: number): Promise<void> {
|
|
423
|
+
const L = TAILSCALE_NUDGE_LIMITS
|
|
424
|
+
const status = await this.freshStatus(at)
|
|
425
|
+
if (!status || status.backendState !== 'Running') {
|
|
426
|
+
if (at - pending.firedAt > L.eventExpiryMs) {
|
|
427
|
+
this.pending = null
|
|
428
|
+
this.log(`[tailscale-nudge] trigger=${pending.trigger} dropped: Tailscale ${status ? 'not running' : 'did not answer'} for ${Math.round((at - pending.firedAt) / 1000)}s`)
|
|
429
|
+
}
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (this.pending === pending) this.pending = null
|
|
433
|
+
const known = (ip: string) => {
|
|
434
|
+
const t = this.peers.get(ip)
|
|
435
|
+
return !!t && t.lastAuthenticatedAt > 0 && at - t.lastAuthenticatedAt <= L.knownPeerWindowMs
|
|
436
|
+
}
|
|
437
|
+
const eligible = status.peers.filter(p => p.online && (known(p.ip) || isMobileOs(p.os)))
|
|
438
|
+
// Already back since the event: nothing to heal.
|
|
439
|
+
const backSince = (p: PeerStatus) => (this.peers.get(p.ip)?.lastRequestAt ?? 0) >= pending.firedAt
|
|
440
|
+
// 6.90.0: say so once per event, so a quiet log after a wake reads as "the phone came back
|
|
441
|
+
// on its own", not "the trigger never ran". Trigger and outcome only, never an address.
|
|
442
|
+
if (eligible.some(backSince)) this.log(`[tailscale-nudge] trigger=${pending.trigger} outcome=already_reconnected`)
|
|
443
|
+
const candidates = eligible
|
|
444
|
+
.filter(p => !backSince(p))
|
|
445
|
+
.sort((a, b) => Number(known(b.ip)) - Number(known(a.ip)))
|
|
446
|
+
.slice(0, L.eventMaxPeers)
|
|
447
|
+
for (const peer of candidates) {
|
|
448
|
+
if (!this.enabled()) return
|
|
449
|
+
await this.maybeNudge(peer, pending.trigger, status, at, 0)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private async runSilent(at: number): Promise<void> {
|
|
454
|
+
const L = TAILSCALE_NUDGE_LIMITS
|
|
455
|
+
const meetingOpen = safe(this.activeMeetings, 0) > 0
|
|
456
|
+
const silent: Array<[string, PeerTrack]> = []
|
|
457
|
+
for (const [ip, t] of this.peers) {
|
|
458
|
+
const expected = (t.lastAuthenticatedAt > 0 && at - t.lastAuthenticatedAt <= L.activeWindowMs)
|
|
459
|
+
|| (meetingOpen && t.lastMeetingChunkAt > 0 && at - t.lastMeetingChunkAt <= L.meetingWindowMs)
|
|
460
|
+
// Only a peer the caps would let us nudge now: a status read for one we cannot nudge is waste.
|
|
461
|
+
if (expected && at - t.lastRequestAt >= L.silenceMs && this.allowed(ip, at)) silent.push([ip, t])
|
|
462
|
+
}
|
|
463
|
+
if (silent.length === 0) return
|
|
464
|
+
// Never on absence alone: only a peer that Tailscale itself reports Online is nudged.
|
|
465
|
+
const status = await this.freshStatus(at)
|
|
466
|
+
if (!status || status.backendState !== 'Running') return
|
|
467
|
+
for (const [ip, t] of silent) {
|
|
468
|
+
const peer = status.peers.find(p => p.ip === ip)
|
|
469
|
+
if (!peer || !peer.online) continue
|
|
470
|
+
if (!this.enabled()) return
|
|
471
|
+
await this.maybeNudge(peer, 'silent', status, at, at - t.lastRequestAt)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private allowed(ip: string, at: number): boolean {
|
|
476
|
+
const L = TAILSCALE_NUDGE_LIMITS
|
|
477
|
+
while (this.hourLog.length > 0 && at - this.hourLog[0] >= 3_600_000) this.hourLog.shift()
|
|
478
|
+
if (this.hourLog.length >= L.maxPerHour) return false
|
|
479
|
+
const t = this.peers.get(ip)
|
|
480
|
+
if (!t || t.lastNudgeAt === 0) return true
|
|
481
|
+
return at - t.lastNudgeAt >= Math.max(L.minIntervalMs, backoffMs(t.consecutive))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private async maybeNudge(peer: PeerStatus, trigger: NudgeTrigger, status: TailscaleStatus, at: number, silentMs: number): Promise<void> {
|
|
485
|
+
if (!this.allowed(peer.ip, at)) return
|
|
486
|
+
let track = this.peers.get(peer.ip)
|
|
487
|
+
if (!track) {
|
|
488
|
+
track = { lastRequestAt: 0, lastAuthenticatedAt: 0, lastMeetingChunkAt: 0, lastNudgeAt: 0, consecutive: 0 }
|
|
489
|
+
this.peers.set(peer.ip, track)
|
|
490
|
+
while (this.peers.size > TAILSCALE_NUDGE_LIMITS.maxTrackedPeers) this.peers.delete(this.peers.keys().next().value as string)
|
|
491
|
+
}
|
|
492
|
+
track.lastNudgeAt = at
|
|
493
|
+
track.consecutive += 1
|
|
494
|
+
this.hourLog.push(at)
|
|
495
|
+
const bin = this.bin as string
|
|
496
|
+
const disco = await this.runner(bin, DISCO_PING_ARGS(peer.ip), TAILSCALE_NUDGE_LIMITS.discoTimeoutMs)
|
|
497
|
+
const outcome = classifyDiscoPing(disco)
|
|
498
|
+
if (disco.spawnError === 'ENOENT') this.bin = null
|
|
499
|
+
let dataPath: DataPathOutcome = 'skipped'
|
|
500
|
+
let icmpMs = 0
|
|
501
|
+
if (outcome === 'pong_direct' || outcome === 'pong_relay') {
|
|
502
|
+
const icmp = await this.runner(bin, ICMP_PING_ARGS(peer.ip), TAILSCALE_NUDGE_LIMITS.icmpTimeoutMs)
|
|
503
|
+
dataPath = classifyIcmpPing(icmp)
|
|
504
|
+
icmpMs = icmp.elapsedMs
|
|
505
|
+
}
|
|
506
|
+
this.lastNudge = { at, ip: peer.ip, trigger, outcome, dataPath }
|
|
507
|
+
const prev = this.previousStatus?.peers.find(p => p.ip === peer.ip)
|
|
508
|
+
const rx = prev && this.previousStatus ? ` rxDelta=${peer.rxBytes - prev.rxBytes}/${Math.round((status.at - this.previousStatus.at) / 1000)}s` : ''
|
|
509
|
+
const hs = peer.lastHandshakeMs > 0 ? ` handshakeAgeS=${Math.round((status.at - peer.lastHandshakeMs) / 1000)}` : ' handshakeAgeS=none'
|
|
510
|
+
const failure = outcome === 'error' || outcome === 'timeout' || outcome === 'unavailable'
|
|
511
|
+
? ` code=${disco.code} signal=${disco.signal ?? 'none'} killed=${disco.killed}${disco.spawnError ? ` spawn=${disco.spawnError}` : ''}`
|
|
512
|
+
: ''
|
|
513
|
+
this.log(`[tailscale-nudge] trigger=${trigger} outcome=${outcome} dataPath=${dataPath} pingMs=${disco.elapsedMs} icmpMs=${icmpMs}`
|
|
514
|
+
+ `${silentMs > 0 ? ` silentS=${Math.round(silentMs / 1000)}` : ''}${hs}${rx} inRow=${track.consecutive} lastHour=${this.hourLog.length}${failure}`)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function safe<T>(fn: () => T, fallback: T): T {
|
|
519
|
+
try { return fn() } catch { return fallback }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let singleton: TailscaleNudger | null = null
|
|
523
|
+
/** The server's nudger. Created on first use so /api/health can read it before start(). */
|
|
524
|
+
export function getTailscaleNudger(): TailscaleNudger {
|
|
525
|
+
if (!singleton) singleton = new TailscaleNudger()
|
|
526
|
+
return singleton
|
|
527
|
+
}
|
|
528
|
+
/** index.ts installs the server's nudger with its real meeting probe, before any request is noted. */
|
|
529
|
+
export function installTailscaleNudger(deps: TailscaleNudgeDeps = {}): TailscaleNudger {
|
|
530
|
+
singleton?.stop()
|
|
531
|
+
singleton = new TailscaleNudger(deps)
|
|
532
|
+
return singleton
|
|
533
|
+
}
|
|
534
|
+
export function __setTailscaleNudgerForTests(nudger: TailscaleNudger | null): void {
|
|
535
|
+
singleton = nudger
|
|
536
|
+
}
|
|
537
|
+
/** `{ tailscaleNudge }` for /api/health. Public route: no address, no peer name, counts and times only. */
|
|
538
|
+
export function tailscaleNudgeHealthField(): { tailscaleNudge: TailscaleNudgeHealth } {
|
|
539
|
+
return { tailscaleNudge: getTailscaleNudger().health() }
|
|
540
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -75,7 +75,7 @@ import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
|
75
75
|
import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
|
|
76
76
|
import { liveMeetingCapability } from '../lib/live-provisional.js'
|
|
77
77
|
import { CUE_KINDS } from '../../shared/live-cues-v2.js'
|
|
78
|
-
import {
|
|
78
|
+
import { tailscaleNudgeHealthField } from '../lib/tailscale-nudge.js'
|
|
79
79
|
|
|
80
80
|
/** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
|
|
81
81
|
* is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
|
|
@@ -446,6 +446,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
446
446
|
...continueLiveHealthFields(),
|
|
447
447
|
// 6.62.0 /qa (W6): spawn isolation, posture and Cursor model read counters.
|
|
448
448
|
...continueReadHealthFields(),
|
|
449
|
+
// 6.89.0 Tailscale self-heal: switch, last nudge and its outcome, count this hour. No address.
|
|
450
|
+
...tailscaleNudgeHealthField(),
|
|
449
451
|
// 6.62.0: ONE providers object per engine. `act` (plan 3.11): what each provider can do,
|
|
450
452
|
// with a reason for every "no". `observe` (plan 1.10): what COS can see of its sessions.
|
|
451
453
|
providers: mergeProviderSections(
|
|
@@ -548,9 +550,6 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
548
550
|
tasks: {
|
|
549
551
|
gate: taskBridgeAvailable() ? 'ready' : 'disabled',
|
|
550
552
|
},
|
|
551
|
-
// 6.88.0 LAN first (docs/lan-first.md): protocol flags only. This route is public, so
|
|
552
|
-
// the addresses themselves are published only on authenticated /api/models.
|
|
553
|
-
...lanFirstCapabilities({ token: process.env.COS_API_TOKEN ?? '', serverInstanceId: getServerInstanceId() }),
|
|
554
553
|
},
|
|
555
554
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
556
555
|
// Publish capability only; job counts, retention identities, subscriber
|
|
@@ -642,10 +641,6 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
642
641
|
// Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
|
|
643
642
|
...keyProfilesModelsField(),
|
|
644
643
|
serverInstanceId: getServerInstanceId(),
|
|
645
|
-
// 6.88.0 LAN first (docs/lan-first.md): the addresses this Mac answers on, behind the
|
|
646
|
-
// token. Absent when the switch is off, the instance id is not minted yet, or the
|
|
647
|
-
// interface read failed; never a reason for this response to fail.
|
|
648
|
-
...lanRoutesModelsField(),
|
|
649
644
|
// Minted here rather than on a route of its own: this response is already
|
|
650
645
|
// authenticated, and the mint is stateless, so it costs nothing and stores
|
|
651
646
|
// nothing.
|
|
@@ -741,16 +736,9 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
741
736
|
tasks: {
|
|
742
737
|
gate: taskBridgeAvailable() ? 'ready' : 'disabled',
|
|
743
738
|
},
|
|
744
|
-
// 6.88.0 LAN first: the same helper as /api/health, so the two surfaces cannot disagree.
|
|
745
|
-
...lanFirstCapabilities({ token: apiToken, serverInstanceId: getServerInstanceId() }),
|
|
746
739
|
},
|
|
747
740
|
})
|
|
748
741
|
})
|
|
749
|
-
/** 6.88.0: `{ routes }` for /api/models, or nothing at all. lanRoutesField never throws. */
|
|
750
|
-
function lanRoutesModelsField(): { routes?: NonNullable<ReturnType<typeof lanRoutesField>> } {
|
|
751
|
-
const routes = lanRoutesField({ port: lanFirstHttpPort(), serverInstanceId: getServerInstanceId() })
|
|
752
|
-
return routes ? { routes } : {}
|
|
753
|
-
}
|
|
754
742
|
/** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
|
|
755
743
|
export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
|
|
756
744
|
const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })
|
|
@@ -2215,6 +2215,10 @@ async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Bu
|
|
|
2215
2215
|
}
|
|
2216
2216
|
}
|
|
2217
2217
|
|
|
2218
|
+
/** 6.90.0 calibration tags for Mac audio: the whole-chunk shadow score, and one Nemotron track. */
|
|
2219
|
+
export const MAC_SHADOW_EVENT = 'mac-shadow'
|
|
2220
|
+
export const MAC_TRACK_EVENT = 'mac-nemotron-track'
|
|
2221
|
+
|
|
2218
2222
|
/** The wearer's label for the 6.61.1 owner guard; null when the profile cannot say. */
|
|
2219
2223
|
function ownerLabelOrNull(): string | null {
|
|
2220
2224
|
try { return getOwnerSpeakerLabel() } catch { return null }
|
|
@@ -2227,7 +2231,7 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
|
|
|
2227
2231
|
if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return policyLabel({ speaker: clientSpeaker, similarity: 0 }, policy)
|
|
2228
2232
|
|
|
2229
2233
|
const tEmb = performance.now()
|
|
2230
|
-
const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers, { minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined, ...(MAC_CAPTURE_ID.test(sessionId) ? { prioritizeOwner: false, event: 'mac-system-audio' } : {}) })
|
|
2234
|
+
const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers, { minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined, ...(MAC_CAPTURE_ID.test(sessionId) ? { prioritizeOwner: false, event: 'mac-system-audio', shadowEvent: MAC_SHADOW_EVENT } : {}) })
|
|
2231
2235
|
console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
|
|
2232
2236
|
if (!embeddingResult) return policyLabel({ speaker: clientSpeaker, similarity: 0 }, policy)
|
|
2233
2237
|
|
|
@@ -2537,7 +2541,14 @@ async function processStreamChunk(opts: {
|
|
|
2537
2541
|
identify: wav => {
|
|
2538
2542
|
const policy = capturePolicy
|
|
2539
2543
|
if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2540
|
-
|
|
2544
|
+
// 6.90.0: a Mac track is never checked against the wearer first (the wearer
|
|
2545
|
+
// is not in Mac playback) and is tagged so its calibration rows are separable.
|
|
2546
|
+
const mac = MAC_CAPTURE_ID.test(sessionId)
|
|
2547
|
+
const named = identifySpeaker(wav, undefined, {
|
|
2548
|
+
event: mac ? MAC_TRACK_EVENT : 'nemotron-track',
|
|
2549
|
+
minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined,
|
|
2550
|
+
...(mac ? { prioritizeOwner: false } : {}),
|
|
2551
|
+
})
|
|
2541
2552
|
return named ? policyLabel({ speaker: named.speaker, similarity: named.similarity }, policy) : null
|
|
2542
2553
|
},
|
|
2543
2554
|
})
|