@trillboards/edge-sdk 0.2.19 → 0.2.20

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.
@@ -0,0 +1,701 @@
1
+ "use strict";
2
+ /**
3
+ * connectorUpdate — the CONSUMER the published version manifest never had.
4
+ *
5
+ * Every release publishes `trillboards-connector-<platform>.version.json` to the
6
+ * CDN with `connectorVersion`, `gitSha`, `sha256`, `sizeBytes` and `publishedAt`.
7
+ * Until this module existed, repo-wide the ONLY references to that object were
8
+ * the workflow that writes it, a test asserting the workflow writes it, and a
9
+ * README line. It was a producer with no consumer — the mirror image of the
10
+ * defect class in `docs/runbooks/telemetry-producer-substrate.md`, and just as
11
+ * invisible: the artifact that says "you are out of date" was published,
12
+ * correct, and read by nobody, so a field fix reached a customer only when that
13
+ * customer happened to re-download 2.6 GB and re-run the installer.
14
+ *
15
+ * THIS FILE ONLY CHECKS AND REPORTS. Applying an update is a separate,
16
+ * platform-specific transaction — see `deploy/windows/apply-update.ps1` and
17
+ * `connectorUpdateApply.ts`. The split is deliberate: "am I stale?" is a
18
+ * question every platform can answer over one 401-byte GET, while "swap the
19
+ * running binaries" is a Windows scheduled-task transaction with a rollback.
20
+ *
21
+ * ── WHY A 200 IS NOT AN ANSWER ──────────────────────────────────────────────
22
+ * The CDN in front of these objects serves the marketing SPA as its default
23
+ * behavior, so a MISSING manifest answers `HTTP 200 text/html`, not 404. Proved
24
+ * against production while writing this:
25
+ *
26
+ * $ curl -o /dev/null -w '%{http_code} %{content_type}\n' \
27
+ * .../connector/this-file-does-not-exist-control.json
28
+ * 200 text/html
29
+ *
30
+ * That is the exact shape of `.claude/lessons/2026-08-14-a-broken-probe-and-a-
31
+ * true-absence-are-the-same-bytes.md`. So the check is not "did the GET
32
+ * succeed"; it is "did the body parse as a manifest whose every required field
33
+ * is present and well-formed". A body that fails that test yields `null` —
34
+ * meaning UNKNOWN — and `null` is never rendered as "up to date". A staleness
35
+ * oracle that reports "current" when it cannot reach its own oracle is worse
36
+ * than no oracle at all.
37
+ *
38
+ * For the same reason `parseConnectorVersion` is STRICT rather than lenient.
39
+ * `edge-core`'s ModelManager parses versions with `.split('.').map(Number)` and
40
+ * `|| 0`, so a non-numeric version silently becomes `0.0.0` and every later
41
+ * comparison quietly answers "not newer" — forever, with no error anywhere. A
42
+ * version we cannot parse is a manifest we do not trust.
43
+ */
44
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
45
+ if (k2 === undefined) k2 = k;
46
+ var desc = Object.getOwnPropertyDescriptor(m, k);
47
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
48
+ desc = { enumerable: true, get: function() { return m[k]; } };
49
+ }
50
+ Object.defineProperty(o, k2, desc);
51
+ }) : (function(o, m, k, k2) {
52
+ if (k2 === undefined) k2 = k;
53
+ o[k2] = m[k];
54
+ }));
55
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
56
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
57
+ }) : function(o, v) {
58
+ o["default"] = v;
59
+ });
60
+ var __importStar = (this && this.__importStar) || (function () {
61
+ var ownKeys = function(o) {
62
+ ownKeys = Object.getOwnPropertyNames || function (o) {
63
+ var ar = [];
64
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
65
+ return ar;
66
+ };
67
+ return ownKeys(o);
68
+ };
69
+ return function (mod) {
70
+ if (mod && mod.__esModule) return mod;
71
+ var result = {};
72
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
73
+ __setModuleDefault(result, mod);
74
+ return result;
75
+ };
76
+ })();
77
+ Object.defineProperty(exports, "__esModule", { value: true });
78
+ exports.CONNECTOR_UPDATE_SAFE_POINT_DEADLINE_MS = exports.CONNECTOR_UPDATE_STAGE_DEADLINE_MS = exports.CONNECTOR_UPDATE_POLL_INTERVAL_MS = exports.CONNECTOR_UPDATE_INITIAL_DELAY_MS = exports.ConnectorManifestClient = exports.defaultManifestFetcher = exports.CONNECTOR_UPDATE_MIN_RELEASE_AGE_MS = exports.CONNECTOR_UPDATE_CHECK_INTERVAL_MS = exports.CONNECTOR_UPDATE_BASE_URL = void 0;
79
+ exports.parseConnectorVersion = parseConnectorVersion;
80
+ exports.isNewerConnectorVersion = isNewerConnectorVersion;
81
+ exports.manifestObjectForPlatform = manifestObjectForPlatform;
82
+ exports.manifestUrlForPlatform = manifestUrlForPlatform;
83
+ exports.parseVersionManifest = parseVersionManifest;
84
+ exports.isSafeToApplyUpdate = isSafeToApplyUpdate;
85
+ exports.busynessOf = busynessOf;
86
+ exports.isReleaseBakedEnough = isReleaseBakedEnough;
87
+ exports.checkForConnectorUpdate = checkForConnectorUpdate;
88
+ exports.startConnectorUpdateService = startConnectorUpdateService;
89
+ exports.connectorDeviceTokens = connectorDeviceTokens;
90
+ exports.startConnectorUpdates = startConnectorUpdates;
91
+ const http = __importStar(require("http"));
92
+ const https = __importStar(require("https"));
93
+ const ConnectorStatusClient_1 = require("./ConnectorStatusClient");
94
+ /** Where the release workflows publish every platform's manifest. */
95
+ exports.CONNECTOR_UPDATE_BASE_URL = 'https://screen.trillboards.com/connector';
96
+ /**
97
+ * How often an install asks whether it is stale.
98
+ *
99
+ * Six hours. The GET is ~401 bytes, so the poll itself costs nothing, and the
100
+ * release workflow fires on every `production` push touching the edge
101
+ * workspaces (measured: 15 releases on 2026-08-13, 13 on 08-12), so a shorter
102
+ * period would only discover the same churn sooner. The FIRST check is deferred
103
+ * one whole interval, matching `ModelManager.start()` — a connector in a crash
104
+ * loop must not turn into a CDN hammer, and a machine that just installed is by
105
+ * definition current.
106
+ */
107
+ exports.CONNECTOR_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
108
+ /**
109
+ * How old a release must be before an install will TAKE it.
110
+ *
111
+ * There is no canary ring in front of this. With ~13 releases a day reaching a
112
+ * fleet that auto-applies, a bad connector would be on every customer's PC
113
+ * within one check period, and the only recovery is another release. A bake
114
+ * window is the cheapest thing standing between "we merged a regression" and
115
+ * "every store stopped sensing", and it costs a fix at most one extra check.
116
+ *
117
+ * `publishedAt` comes from the manifest, i.e. from the same channel as the
118
+ * payload, so this is a release-hygiene control and NOT a security control. The
119
+ * security control is the Authenticode verification in the applier.
120
+ */
121
+ exports.CONNECTOR_UPDATE_MIN_RELEASE_AGE_MS = 6 * 60 * 60 * 1000;
122
+ /** Hard cap on the manifest body. It is ~401 bytes; anything near this is hostile. */
123
+ const MANIFEST_MAX_BYTES = 64 * 1024;
124
+ /** Manifest fetch timeout. */
125
+ const MANIFEST_TIMEOUT_MS = 15000;
126
+ /**
127
+ * Parse `MAJOR.MINOR.PATCH` (an optional leading `v`, an optional `-prerelease`
128
+ * or `+build` suffix that is IGNORED for ordering). Returns null — never a
129
+ * zeroed struct — for anything else.
130
+ */
131
+ function parseConnectorVersion(raw) {
132
+ if (typeof raw !== 'string')
133
+ return null;
134
+ const m = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:[-+][0-9A-Za-z.-]+)?$/.exec(raw.trim());
135
+ if (!m)
136
+ return null;
137
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
138
+ }
139
+ /**
140
+ * Is `available` strictly newer than `installed`?
141
+ *
142
+ * FALSE whenever either side fails to parse. That direction is deliberate: an
143
+ * unparseable version must never authorise a swap of the running binaries. It
144
+ * also makes a DOWNGRADE impossible, which is the whole defence against a CDN
145
+ * that has been rolled back to an older (but still validly signed) payload.
146
+ */
147
+ function isNewerConnectorVersion(available, installed) {
148
+ const a = parseConnectorVersion(available);
149
+ const i = parseConnectorVersion(installed);
150
+ if (!a || !i)
151
+ return false;
152
+ if (a.major !== i.major)
153
+ return a.major > i.major;
154
+ if (a.minor !== i.minor)
155
+ return a.minor > i.minor;
156
+ return a.patch > i.patch;
157
+ }
158
+ /**
159
+ * The manifest object name for a platform, or null where no manifest is
160
+ * published. Linux has no packaged connector release and therefore no manifest;
161
+ * returning null keeps a Linux install from polling a URL that would answer 200
162
+ * with the marketing SPA on every check, forever.
163
+ */
164
+ function manifestObjectForPlatform(platform) {
165
+ if (platform === 'win32')
166
+ return 'trillboards-connector-windows.version.json';
167
+ if (platform === 'darwin')
168
+ return 'trillboards-connector-macos.version.json';
169
+ return null;
170
+ }
171
+ /** Full manifest URL for a platform, or null. */
172
+ function manifestUrlForPlatform(platform, baseUrl = exports.CONNECTOR_UPDATE_BASE_URL) {
173
+ const object = manifestObjectForPlatform(platform);
174
+ if (!object)
175
+ return null;
176
+ return `${baseUrl.replace(/\/+$/, '')}/${object}`;
177
+ }
178
+ function isNonEmptyString(v) {
179
+ return typeof v === 'string' && v.length > 0;
180
+ }
181
+ /**
182
+ * Strictly validate a manifest body. Returns null when ANY required field is
183
+ * absent or malformed — including when the body is the CDN's HTML SPA fallback,
184
+ * which is what a deleted manifest looks like over the wire.
185
+ */
186
+ function parseVersionManifest(body) {
187
+ let raw;
188
+ try {
189
+ raw = JSON.parse(body);
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
195
+ return null;
196
+ const o = raw;
197
+ if (o.schemaVersion !== 1)
198
+ return null;
199
+ if (!isNonEmptyString(o.platform))
200
+ return null;
201
+ if (!parseConnectorVersion(o.connectorVersion))
202
+ return null;
203
+ if (!isNonEmptyString(o.gitSha))
204
+ return null;
205
+ // 64 lowercase hex. The applier compares against this byte for byte, so a
206
+ // shape it cannot use must not survive parsing.
207
+ if (typeof o.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.sha256))
208
+ return null;
209
+ if (typeof o.sizeBytes !== 'number' || !Number.isInteger(o.sizeBytes) || o.sizeBytes <= 0) {
210
+ return null;
211
+ }
212
+ if (!isNonEmptyString(o.publishedAt) || Number.isNaN(Date.parse(o.publishedAt)))
213
+ return null;
214
+ if (!isNonEmptyString(o.downloadUrl))
215
+ return null;
216
+ const manifest = {
217
+ schemaVersion: 1,
218
+ platform: o.platform,
219
+ connectorVersion: o.connectorVersion,
220
+ gitSha: o.gitSha,
221
+ sha256: o.sha256,
222
+ sizeBytes: o.sizeBytes,
223
+ publishedAt: o.publishedAt,
224
+ downloadUrl: o.downloadUrl,
225
+ };
226
+ if (isNonEmptyString(o.updateUrl))
227
+ manifest.updateUrl = o.updateUrl;
228
+ if (isNonEmptyString(o.updateCatalogUrl))
229
+ manifest.updateCatalogUrl = o.updateCatalogUrl;
230
+ return manifest;
231
+ }
232
+ /**
233
+ * GET a small public object with a HARD byte cap.
234
+ *
235
+ * Deliberately not `AudienceAnalyzeClient`'s `defaultHttpPoster`: that one is a
236
+ * POST-shaped transport that concatenates every chunk with no ceiling, which is
237
+ * fine for an API we authenticate to and wrong for an unauthenticated public
238
+ * object whose expected size is 401 bytes. A response that keeps coming is
239
+ * destroyed rather than buffered.
240
+ *
241
+ * `Cache-Control: no-cache` + `Pragma: no-cache` are belt-and-braces. The object
242
+ * is published `no-cache, no-store, must-revalidate` precisely because it is the
243
+ * staleness oracle, but an intercepting corporate proxy is not obliged to
244
+ * honour a response header, and a cached manifest reports the PREVIOUS release
245
+ * as current for the whole TTL.
246
+ */
247
+ const defaultManifestFetcher = (url, timeoutMs) => new Promise((resolve, reject) => {
248
+ let parsed;
249
+ try {
250
+ parsed = new URL(url);
251
+ }
252
+ catch (err) {
253
+ reject(new Error(`Invalid connector manifest URL: ${err.message}`));
254
+ return;
255
+ }
256
+ const mod = parsed.protocol === 'https:' ? https : http;
257
+ const req = mod.request(url, {
258
+ method: 'GET',
259
+ headers: {
260
+ Accept: 'application/json',
261
+ 'Cache-Control': 'no-cache',
262
+ Pragma: 'no-cache',
263
+ },
264
+ timeout: timeoutMs,
265
+ }, (res) => {
266
+ const chunks = [];
267
+ let total = 0;
268
+ res.on('data', (chunk) => {
269
+ total += chunk.length;
270
+ if (total > MANIFEST_MAX_BYTES) {
271
+ res.destroy();
272
+ reject(new Error('connector manifest exceeded the maximum size'));
273
+ return;
274
+ }
275
+ chunks.push(chunk);
276
+ });
277
+ res.on('end', () => {
278
+ resolve({
279
+ statusCode: res.statusCode || 0,
280
+ body: Buffer.concat(chunks).toString('utf-8'),
281
+ });
282
+ });
283
+ });
284
+ req.on('error', (err) => reject(err));
285
+ req.on('timeout', () => req.destroy(new Error('connector manifest request timed out')));
286
+ req.end();
287
+ });
288
+ exports.defaultManifestFetcher = defaultManifestFetcher;
289
+ /** Reads the published manifest for one platform. */
290
+ class ConnectorManifestClient {
291
+ constructor(options, fetcher) {
292
+ this.url = manifestUrlForPlatform(options.platform, options.baseUrl);
293
+ this.timeoutMs = options.timeoutMs ?? MANIFEST_TIMEOUT_MS;
294
+ this.fetcher = fetcher ?? exports.defaultManifestFetcher;
295
+ }
296
+ /** The resolved URL, or null on a platform that publishes no manifest. */
297
+ getEndpoint() {
298
+ return this.url;
299
+ }
300
+ /**
301
+ * The published manifest, or null for UNKNOWN — no platform manifest, a
302
+ * transport failure, a non-2xx, or a body that is not a valid manifest (the
303
+ * SPA-fallback case). Never throws; the caller keeps sensing either way.
304
+ */
305
+ async fetchManifest() {
306
+ if (!this.url)
307
+ return null;
308
+ let result;
309
+ try {
310
+ result = await this.fetcher(this.url, this.timeoutMs);
311
+ }
312
+ catch {
313
+ return null;
314
+ }
315
+ if (result.statusCode < 200 || result.statusCode >= 300)
316
+ return null;
317
+ return parseVersionManifest(result.body);
318
+ }
319
+ }
320
+ exports.ConnectorManifestClient = ConnectorManifestClient;
321
+ /**
322
+ * Is this a moment at which the connector may be stopped and replaced?
323
+ *
324
+ * The two quantities are the connector's IRRECOVERABLE work:
325
+ *
326
+ * - `inFlight` — a frame the model is answering, or a POST in flight. There is
327
+ * no store-and-forward on the Windows connector (`SignalBuffer` is
328
+ * constructed only in `EdgeAgent`, which the Windows task never runs), so a
329
+ * kill here loses that observation permanently.
330
+ * - `buffered` — frames the motion cascade has ALREADY decided are
331
+ * interesting: a person entering a restricted area, a spill, a fall. At a
332
+ * 30 s scan cadence the next look is a different event. `CloudSensingManager
333
+ * .stop()` calls `candidates.reset()`, so these die on ANY stop, graceful or
334
+ * not — stopping while the buffer is loaded is the dropped-alert failure, and
335
+ * it is a product failure, not an implementation detail.
336
+ *
337
+ * NOT covered, and stated rather than hidden: a tick can be between
338
+ * `camera.getFrame()` and the admission gate — mid-decode, mid-motion-eval —
339
+ * with `inFlight === 0`, because on the cascade path the ticket is not taken
340
+ * until after the motion verdict. `LoopState.tickInFlight` is private and
341
+ * exposed nowhere. Losing that frame costs one SAMPLE that the cascade has not
342
+ * yet judged interesting, which is a different and much smaller loss than a
343
+ * flagged candidate, and the next tick captures a fresher frame. Widening the
344
+ * health struct to close it was judged not worth the blast radius.
345
+ */
346
+ function isSafeToApplyUpdate(busyness) {
347
+ return busyness.inFlight === 0 && busyness.buffered === 0;
348
+ }
349
+ /**
350
+ * Map a `getSensingHealth()` reading onto the two numbers that matter. Tolerant
351
+ * of a partial shape so a test fake or a future manager cannot crash the
352
+ * watcher; an absent field reads as 0, i.e. "not holding anything", which is
353
+ * only ever reached by a manager that has no buffer to hold anything in.
354
+ */
355
+ function busynessOf(health) {
356
+ return {
357
+ inFlight: Number.isFinite(health?.inFlight) ? health.inFlight : 0,
358
+ buffered: Number.isFinite(health?.candidateBuffer?.buffered)
359
+ ? health.candidateBuffer.buffered
360
+ : 0,
361
+ };
362
+ }
363
+ /** Has this release sat long enough to be taken? */
364
+ function isReleaseBakedEnough(manifest, nowMs, minAgeMs = exports.CONNECTOR_UPDATE_MIN_RELEASE_AGE_MS) {
365
+ const published = Date.parse(manifest.publishedAt);
366
+ if (Number.isNaN(published))
367
+ return false;
368
+ // A publishedAt in the FUTURE (clock skew either side) is not evidence of
369
+ // age. Refusing is the safe direction: the next check is six hours away.
370
+ return nowMs - published >= minAgeMs;
371
+ }
372
+ /**
373
+ * One check: read the manifest, decide whether this install is behind.
374
+ *
375
+ * `availableVersion` is null both when the install is current AND when the
376
+ * manifest could not be read — but `publishedVersion` distinguishes them, so a
377
+ * caller that must not conflate "current" with "unknown" can.
378
+ */
379
+ async function checkForConnectorUpdate(installedVersion, client) {
380
+ const manifest = await client.fetchManifest();
381
+ if (!manifest) {
382
+ return { installedVersion, publishedVersion: null, availableVersion: null, manifest: null };
383
+ }
384
+ const newer = isNewerConnectorVersion(manifest.connectorVersion, installedVersion);
385
+ return {
386
+ installedVersion,
387
+ publishedVersion: manifest.connectorVersion,
388
+ availableVersion: newer ? manifest.connectorVersion : null,
389
+ manifest,
390
+ };
391
+ }
392
+ /**
393
+ * Delay before the FIRST check.
394
+ *
395
+ * `ModelManager` defers its first check a whole hour, and that is right for a
396
+ * 2.4 GB model download. It is wrong here: this GET is 401 bytes, and deferring
397
+ * it six hours would mean a store PC that reboots nightly never reports its
398
+ * version at all — the fleet view would be blind to exactly the machines most
399
+ * likely to be stale. Sixty seconds is long enough for sensing to come up first
400
+ * and short enough that every start contributes a report.
401
+ *
402
+ * A fresh release still cannot be applied on that first check: the bake window
403
+ * is enforced separately, on `publishedAt`.
404
+ */
405
+ exports.CONNECTOR_UPDATE_INITIAL_DELAY_MS = 60000;
406
+ /** How often the apply state machine looks at the world while an update is in flight. */
407
+ exports.CONNECTOR_UPDATE_POLL_INTERVAL_MS = 60000;
408
+ /**
409
+ * How long to wait for staging to finish before giving up on this attempt.
410
+ *
411
+ * An hour. The payload is ~200 MB, and a store uplink is the slow case this
412
+ * number is for. Giving up is not a failure — the next check re-stages.
413
+ */
414
+ exports.CONNECTOR_UPDATE_STAGE_DEADLINE_MS = 60 * 60 * 1000;
415
+ /**
416
+ * How long to wait for a safe point before applying anyway.
417
+ *
418
+ * Two hours, and the branch matters. On a saturated multi-camera install the
419
+ * candidate buffer may never reach zero — measured on a 24 h 32-camera
420
+ * simulation, 89,056 of 92,160 candidates were EVICTED, i.e. the buffer is
421
+ * permanently full and those frames are already being dropped by the buffer's
422
+ * own fairness bound. Waiting forever for a quiet moment on such a machine
423
+ * would mean it never receives another fix, which is the strictly worse
424
+ * outcome. So: take the first genuinely quiet moment; failing that, accept the
425
+ * loss after two hours and record which branch was taken.
426
+ */
427
+ exports.CONNECTOR_UPDATE_SAFE_POINT_DEADLINE_MS = 2 * 60 * 60 * 1000;
428
+ /**
429
+ * The whole update loop: check the manifest, tell the server where this install
430
+ * stands, and — on Windows, with a verified payload and a quiet moment — apply.
431
+ *
432
+ * Every branch is best-effort. A failed check, a failed report, an applier that
433
+ * will not launch: each is one log line, and sensing continues. The one thing
434
+ * this must never do is stop the connector for any reason other than a
435
+ * deliberate, verified handoff.
436
+ */
437
+ function startConnectorUpdateService(deps) {
438
+ const client = deps.client ?? new ConnectorManifestClient({ platform: deps.platform });
439
+ if (!client.getEndpoint())
440
+ return null;
441
+ const log = deps.log ?? (() => { });
442
+ const now = deps.now ?? (() => Date.now());
443
+ const reportStatus = deps.reportStatus ?? ConnectorStatusClient_1.reportConnectorStatus;
444
+ const setTimeoutFn = deps.setTimeoutFn ?? setTimeout;
445
+ const clearTimeoutFn = deps.clearTimeoutFn ?? clearTimeout;
446
+ const setIntervalFn = deps.setIntervalFn ?? setInterval;
447
+ const clearIntervalFn = deps.clearIntervalFn ?? clearInterval;
448
+ const applier = deps.applier ?? null;
449
+ const requestExit = deps.requestExit ?? (() => process.exit(0));
450
+ const pollIntervalMs = deps.pollIntervalMs ?? exports.CONNECTOR_UPDATE_POLL_INTERVAL_MS;
451
+ const stageDeadlineMs = deps.stageDeadlineMs ?? exports.CONNECTOR_UPDATE_STAGE_DEADLINE_MS;
452
+ const safePointDeadlineMs = deps.safePointDeadlineMs ?? exports.CONNECTOR_UPDATE_SAFE_POINT_DEADLINE_MS;
453
+ let stopped = false;
454
+ let phase = 'idle';
455
+ let target = null;
456
+ let phaseStartedMs = 0;
457
+ let pollTimer = null;
458
+ let checkTimer = null;
459
+ let initialTimer = null;
460
+ const unref = (t) => {
461
+ if (t && typeof t.unref === 'function') {
462
+ t.unref();
463
+ }
464
+ };
465
+ const endApply = () => {
466
+ phase = 'idle';
467
+ target = null;
468
+ if (pollTimer) {
469
+ clearIntervalFn(pollTimer);
470
+ pollTimer = null;
471
+ }
472
+ };
473
+ /**
474
+ * Report where this install stands, carrying any update outcome the applier
475
+ * left behind. The outcome is cleared only once the server has ACCEPTED it —
476
+ * a report that failed must be retried, not discarded, or a machine whose
477
+ * update was refused looks identical to one that never tried.
478
+ */
479
+ const report = async (check) => {
480
+ const outcome = applier?.readOutcome() ?? null;
481
+ const result = await reportStatus({
482
+ apiBaseUrl: deps.apiBaseUrl,
483
+ deviceTokens: deps.deviceTokens,
484
+ report: {
485
+ connectorVersion: deps.installedVersion,
486
+ // UNDEFINED when the manifest could not be read, so the server leaves
487
+ // its column alone. `check.availableVersion` is null both for "current"
488
+ // and for "unknown", and reporting the second as the first would let a
489
+ // transient CDN failure erase a real drift and re-stamp the row as
490
+ // freshly-current.
491
+ ...(check.manifest ? { availableVersion: check.availableVersion } : {}),
492
+ updateOutcome: outcome,
493
+ },
494
+ log,
495
+ });
496
+ // Cleared only when EVERY feed's report landed. The outcome file is
497
+ // machine-wide but the report is per feed, so clearing on the first success
498
+ // would lose the attempt for any feed that failed later in the loop — on a
499
+ // 16-camera install, fifteen rows recording a failed update and one that
500
+ // never will.
501
+ if (outcome && result.attempted > 0 && result.accepted === result.attempted) {
502
+ applier?.clearOutcome();
503
+ }
504
+ };
505
+ /** Advance the apply state machine. Called on a short timer while in flight. */
506
+ const pollApply = () => {
507
+ if (stopped || phase === 'idle' || !target || !applier)
508
+ return;
509
+ const elapsed = now() - phaseStartedMs;
510
+ if (phase === 'staging') {
511
+ if (applier.stagedVersion() === target.connectorVersion) {
512
+ log(`[update] ${target.connectorVersion} is staged and verified — waiting for a safe point`);
513
+ phase = 'awaiting-safe-point';
514
+ phaseStartedMs = now();
515
+ return;
516
+ }
517
+ if (elapsed >= stageDeadlineMs) {
518
+ log('[update] staging did not finish in time — will retry at the next check');
519
+ endApply();
520
+ }
521
+ return;
522
+ }
523
+ // awaiting-safe-point
524
+ const busyness = deps.busyness ? deps.busyness() : { inFlight: 0, buffered: 0 };
525
+ const safe = isSafeToApplyUpdate(busyness);
526
+ const overdue = elapsed >= safePointDeadlineMs;
527
+ if (!safe && !overdue)
528
+ return;
529
+ if (!safe) {
530
+ log(`[update] no safe point in ${Math.round(elapsed / 60000)} min — applying anyway ` +
531
+ `(inFlight=${busyness.inFlight}, held candidates=${busyness.buffered})`);
532
+ }
533
+ else {
534
+ log('[update] connector is idle — applying');
535
+ }
536
+ const version = target.connectorVersion;
537
+ endApply();
538
+ if (!applier.commit({ version, currentVersion: deps.installedVersion })) {
539
+ log('[update] could not launch the applier — staying on the current version');
540
+ return;
541
+ }
542
+ // The applier owns the machine now. Exiting frees bin\node.exe for the swap;
543
+ // it also means anything after this line does not run.
544
+ requestExit();
545
+ };
546
+ const beginApply = (manifest) => {
547
+ if (!applier)
548
+ return;
549
+ // A verified tree for THIS version may already be on disk: the connector
550
+ // restarted between staging and the safe point, or the safe point never
551
+ // arrived before the last check gave up. Re-downloading ~200 MB over a
552
+ // store's uplink to arrive at bytes we already hold — and already proved
553
+ // against a signed catalog — is pure waste, and on a connector that
554
+ // restarts often it is waste every six hours. `staged.json` is written last
555
+ // and atomically, so its presence means the whole verification ran.
556
+ const alreadyStaged = applier.stagedVersion() === manifest.connectorVersion;
557
+ if (!alreadyStaged && !applier.stage({ manifest, currentVersion: deps.installedVersion })) {
558
+ log('[update] could not launch the applier — staying on the current version');
559
+ return;
560
+ }
561
+ log(alreadyStaged
562
+ ? `[update] ${manifest.connectorVersion} is already staged — waiting for a safe point`
563
+ : `[update] staging ${manifest.connectorVersion}`);
564
+ phase = alreadyStaged ? 'awaiting-safe-point' : 'staging';
565
+ target = manifest;
566
+ phaseStartedMs = now();
567
+ if (!pollTimer) {
568
+ pollTimer = setIntervalFn(() => {
569
+ try {
570
+ pollApply();
571
+ }
572
+ catch (err) {
573
+ log(`[update] apply step failed (non-fatal): ${String(err)}`);
574
+ }
575
+ }, pollIntervalMs);
576
+ unref(pollTimer);
577
+ }
578
+ };
579
+ const runCheck = async () => {
580
+ if (stopped)
581
+ return;
582
+ const check = await checkForConnectorUpdate(deps.installedVersion, client);
583
+ if (!check.manifest) {
584
+ log('[update] could not read the published version manifest — staleness unknown');
585
+ }
586
+ else if (check.availableVersion) {
587
+ log(`[update] connector ${check.installedVersion} is behind published ${check.availableVersion}`);
588
+ }
589
+ await report(check);
590
+ if (!applier || !check.manifest || !check.availableVersion)
591
+ return;
592
+ if (phase !== 'idle')
593
+ return;
594
+ if (!check.manifest.updateUrl || !check.manifest.updateCatalogUrl) {
595
+ log('[update] the published manifest carries no update payload — reported only');
596
+ return;
597
+ }
598
+ if (!isReleaseBakedEnough(check.manifest, now(), deps.minReleaseAgeMs)) {
599
+ log(`[update] ${check.availableVersion} is too new to take yet — waiting out the bake window`);
600
+ return;
601
+ }
602
+ beginApply(check.manifest);
603
+ };
604
+ const safeRunCheck = () => {
605
+ runCheck().catch((err) => log(`[update] check failed (non-fatal): ${String(err)}`));
606
+ };
607
+ initialTimer = setTimeoutFn(safeRunCheck, deps.initialDelayMs ?? exports.CONNECTOR_UPDATE_INITIAL_DELAY_MS);
608
+ unref(initialTimer);
609
+ checkTimer = setIntervalFn(safeRunCheck, deps.intervalMs ?? exports.CONNECTOR_UPDATE_CHECK_INTERVAL_MS);
610
+ unref(checkTimer);
611
+ return {
612
+ stop() {
613
+ if (stopped)
614
+ return;
615
+ stopped = true;
616
+ try {
617
+ if (initialTimer)
618
+ clearTimeoutFn(initialTimer);
619
+ if (checkTimer)
620
+ clearIntervalFn(checkTimer);
621
+ if (pollTimer)
622
+ clearIntervalFn(pollTimer);
623
+ }
624
+ catch {
625
+ /* best-effort */
626
+ }
627
+ initialTimer = null;
628
+ checkTimer = null;
629
+ pollTimer = null;
630
+ },
631
+ };
632
+ }
633
+ /**
634
+ * Every device token this install holds, de-duplicated.
635
+ *
636
+ * One token per feed, because a token binds to exactly one screen — so a
637
+ * 16-camera store reports 16 times and every screen's row carries the version
638
+ * of the one connector serving it. Structurally typed rather than importing
639
+ * `EdgeAgentConfig` so this stays testable without a config file on disk.
640
+ */
641
+ function connectorDeviceTokens(config) {
642
+ const tokens = [];
643
+ for (const camera of config.cameras ?? []) {
644
+ if (camera?.deviceToken)
645
+ tokens.push(camera.deviceToken);
646
+ }
647
+ if (config.deviceToken)
648
+ tokens.push(config.deviceToken);
649
+ return Array.from(new Set(tokens));
650
+ }
651
+ /**
652
+ * The one call every long-running sensing entrypoint makes.
653
+ *
654
+ * Deliberately NOT wired inside `buildCloudSensingManager`/
655
+ * `buildLocalSensingManager`: `auto-sense` builds BOTH managers and caches them,
656
+ * so a watcher created per manager would give that mode two update loops racing
657
+ * each other into the same swap. It belongs to the PROCESS, so it is started
658
+ * once per entrypoint.
659
+ *
660
+ * `combinedSense` (`sense`) is deliberately absent: the Windows task's
661
+ * `ValidateSet` admits only auto-sense/local-sense/cloud-sense, so that mode
662
+ * never runs on the platform this applies to.
663
+ */
664
+ function startConnectorUpdates(opts) {
665
+ const platform = opts.platform ?? process.platform;
666
+ const apiBaseUrl = opts.config.apiBaseUrl;
667
+ const deviceTokens = connectorDeviceTokens(opts.config);
668
+ if (!apiBaseUrl || deviceTokens.length === 0)
669
+ return null;
670
+ let applier = null;
671
+ if (platform === 'win32') {
672
+ try {
673
+ // Lazy so the module graph stays acyclic and a non-Windows connector never
674
+ // loads the applier at all.
675
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
676
+ const mod = require('./connectorUpdateApply');
677
+ const candidate = new mod.WindowsConnectorUpdateApplier({ log: opts.log });
678
+ applier = candidate.installRoot() ? candidate : null;
679
+ }
680
+ catch {
681
+ applier = null;
682
+ }
683
+ }
684
+ try {
685
+ return startConnectorUpdateService({
686
+ installedVersion: opts.installedVersion,
687
+ platform,
688
+ apiBaseUrl,
689
+ deviceTokens,
690
+ busyness: opts.busyness,
691
+ requestExit: opts.requestExit,
692
+ applier,
693
+ log: opts.log,
694
+ });
695
+ }
696
+ catch {
697
+ // An update loop that cannot start must never stop a connector from sensing.
698
+ return null;
699
+ }
700
+ }
701
+ //# sourceMappingURL=connectorUpdate.js.map