@fleetless/contracts 1.2.0 → 2.0.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/artifacts/constants.json +30 -4
  3. package/artifacts/openapi.json +580 -49
  4. package/artifacts/routes.json +93 -2
  5. package/artifacts/schema/apply-error.schema.json +2 -1
  6. package/artifacts/schema/asset-list-response.schema.json +77 -12
  7. package/artifacts/schema/asset-sync-status.schema.json +35 -8
  8. package/artifacts/schema/asset.schema.json +2 -3
  9. package/artifacts/schema/assets-clear-response.schema.json +23 -0
  10. package/artifacts/schema/bridge-asset-progress.schema.json +14 -8
  11. package/artifacts/schema/bridge-config-applied.schema.json +2 -1
  12. package/artifacts/schema/bridge-link-mode.schema.json +36 -0
  13. package/artifacts/schema/bridge-state.schema.json +6 -1
  14. package/artifacts/schema/client-robot-list-item.schema.json +6 -1
  15. package/artifacts/schema/client-robot-list-response.schema.json +6 -1
  16. package/artifacts/schema/cloud-config.schema.json +90 -5
  17. package/artifacts/schema/cloud-hello-ok.schema.json +45 -0
  18. package/artifacts/schema/cloud-ping.schema.json +27 -1
  19. package/artifacts/schema/config-draft-response.schema.json +90 -5
  20. package/artifacts/schema/config-state.schema.json +2 -1
  21. package/artifacts/schema/config-version-response.schema.json +90 -5
  22. package/artifacts/schema/datapoint-config.schema.json +5 -0
  23. package/artifacts/schema/datapoint-frame.schema.json +4 -0
  24. package/artifacts/schema/datapoint-list-response.schema.json +2 -2
  25. package/artifacts/schema/joint-state-put-request.schema.json +23 -0
  26. package/artifacts/schema/joint-state-put-response.schema.json +24 -0
  27. package/artifacts/schema/org-quota-usage-counts.schema.json +0 -5
  28. package/artifacts/schema/org-quota-usage.schema.json +1 -12
  29. package/artifacts/schema/org-quotas.schema.json +1 -7
  30. package/artifacts/schema/robot-config-doc.schema.json +90 -5
  31. package/artifacts/schema/robot-deletion-summary.schema.json +2 -1
  32. package/artifacts/schema/robot-detail-response.schema.json +63 -2
  33. package/artifacts/schema/robot-list-item.schema.json +15 -1
  34. package/artifacts/schema/robot-list-response.schema.json +15 -1
  35. package/artifacts/schema/robot-token-rotate-response.schema.json +15 -0
  36. package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +14 -8
  37. package/artifacts/schema-outgoing/bridge-config-applied.schema.json +2 -1
  38. package/artifacts/schema-outgoing/bridge-link-mode.schema.json +37 -0
  39. package/artifacts/schema-outgoing/datapoint-frame.schema.json +4 -0
  40. package/dist/assets.d.ts +85 -50
  41. package/dist/assets.js +152 -62
  42. package/dist/audit.d.ts +1 -1
  43. package/dist/audit.js +1 -1
  44. package/dist/client-robots.d.ts +2 -0
  45. package/dist/common.d.ts +10 -0
  46. package/dist/common.js +16 -1
  47. package/dist/config.d.ts +69 -1
  48. package/dist/config.js +86 -6
  49. package/dist/errors.d.ts +1 -1
  50. package/dist/errors.js +1 -8
  51. package/dist/index.d.ts +8 -8
  52. package/dist/index.js +4 -4
  53. package/dist/protocol.d.ts +150 -71
  54. package/dist/protocol.js +144 -87
  55. package/dist/rest.d.ts +137 -35
  56. package/dist/rest.js +98 -66
  57. package/dist/routes.js +57 -8
  58. package/package.json +1 -1
  59. package/artifacts/schema/bridge-pressure.schema.json +0 -292
@@ -1,18 +1,67 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import { z } from 'zod';
3
3
  /**
4
- * Bridge <-> cloud protocol, version 2.
4
+ * Bridge <-> cloud protocol, version 3.
5
5
  *
6
- * The version is exchanged in the hello handshake; the cloud refuses an
7
- * incompatible bridge: `protocol_mismatch`, which names both versions and
8
- * reaches the robot's detail view as `last_hello_error`.
6
+ * The version is exchanged in the hello handshake. Since 2026-09 the cloud
7
+ * serves a **window** of versions, not one: every entry of
8
+ * `PROTOCOL_VERSIONS` whose sunset has not passed. A version is deprecated
9
+ * by the cloud release that supersedes it and sunset `PROTOCOL_SUNSET_DAYS`
10
+ * later. Outside the window the cloud refuses with `protocol_mismatch`,
11
+ * which names the window and reaches the robot's detail view as
12
+ * `last_hello_error`.
13
+ *
14
+ * **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
15
+ * sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
16
+ * `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
17
+ * its sunset, and the cloud's protocol-2 adapter owes it two translations on
18
+ * the way in: it drops its pressure datapoints, and it rewrites an
19
+ * `asset_progress` failure of kind `too_large` — a kind protocol 3 no longer
20
+ * has — to `refused` with `details: null`, because a 2.0.0 `bridgeAssetProgress`
21
+ * refuses the frame outright otherwise.
9
22
  *
10
23
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
11
- * beside `message`. The check is `!==`, not a floor, so a bridge that is not
12
- * exactly this version is refused entirely. That is deliberate: a cloud and a
13
- * bridge that disagree about the wire should not pretend otherwise.
24
+ * beside `message`.
25
+ */
26
+ export declare const PROTOCOL_VERSION = 3;
27
+ /** Days between a version's deprecation and its sunset. */
28
+ export declare const PROTOCOL_SUNSET_DAYS = 90;
29
+ export interface ProtocolVersionEntry {
30
+ version: number;
31
+ /** The first bridge package version that speaks this protocol. */
32
+ bridge_from: string;
33
+ /** ISO date of the cloud release that superseded it; null while current. */
34
+ deprecated_at: string | null;
35
+ }
36
+ /**
37
+ * Every protocol version the cloud has served, oldest first. A test keeps
38
+ * exactly one entry current and equal to `PROTOCOL_VERSION`; `test/changelog.test.ts`
39
+ * requires the CHANGELOG's current section to name the newest `bridge_from`
40
+ * and the previous entry's `sunsetOf(...)` date, and `scripts/verify-version-tag.mjs`
41
+ * requires a dated heading for the tag being released.
14
42
  */
15
- export declare const PROTOCOL_VERSION = 2;
43
+ export declare const PROTOCOL_VERSIONS: readonly ProtocolVersionEntry[];
44
+ /** The newest bridge package. The cloud mails organisations still below it. */
45
+ export declare const LATEST_BRIDGE_VERSION = "4.0.0";
46
+ export interface ProtocolStatus {
47
+ status: 'current' | 'deprecated' | 'unsupported';
48
+ /** ISO date, or null for a current or unknown version. */
49
+ sunset_at: string | null;
50
+ }
51
+ export declare function sunsetOf(entry: ProtocolVersionEntry): string | null;
52
+ /**
53
+ * The window rule itself: `protocolStatus` and `minimumProtocolVersion` are
54
+ * this function over `PROTOCOL_VERSIONS`, and the cloud calls it directly.
55
+ *
56
+ * The table is a parameter because callers pass one with a deprecated entry —
57
+ * tests, and any caller reasoning about a sunset. The real table has none
58
+ * until the first bump, so a hard-coded `PROTOCOL_VERSIONS` would leave the
59
+ * deprecated and unsupported branches unreachable.
60
+ */
61
+ export declare function statusFromTable(table: readonly ProtocolVersionEntry[], version: number, today: Date): ProtocolStatus;
62
+ export declare function protocolStatus(version: number, today?: Date): ProtocolStatus;
63
+ /** The lowest version still inside its window today. */
64
+ export declare function minimumProtocolVersion(today?: Date): number;
16
65
  /**
17
66
  * The bridge socket close code for "this robot no longer exists".
18
67
  *
@@ -24,6 +73,20 @@ export declare const PROTOCOL_VERSION = 2;
24
73
  * more informative than "connection closed".
25
74
  */
26
75
  export declare const CLOSE_ROBOT_DELETED = 4004;
76
+ /**
77
+ * The bridge socket close code for "the token you connected with is gone".
78
+ *
79
+ * The cloud closes a robot's live socket with this after the robot's token was
80
+ * rotated. A 4.0.0 bridge reads it the way it reads `invalid_token` — stop,
81
+ * exit 2 — because the secret it holds is no longer a secret anyone accepts,
82
+ * and no amount of reconnecting produces the new one. A 3.x bridge does not
83
+ * know the code, reconnects, and is refused at hello; that ends the same way,
84
+ * one round trip later.
85
+ *
86
+ * Its own code rather than `CLOSE_ROBOT_DELETED`, which would tell an operator
87
+ * their robot had been deleted when it very much still exists.
88
+ */
89
+ export declare const CLOSE_TOKEN_ROTATED = 4005;
27
90
  /**
28
91
  * How long a command waits for its answer when the caller names no patience
29
92
  * of its own.
@@ -113,10 +176,26 @@ export declare const bridgeHello: z.ZodObject<{
113
176
  }, z.core.$strip>>>;
114
177
  }, z.core.$strip>;
115
178
  export type BridgeHello = z.infer<typeof bridgeHello>;
116
- /** Cloud accepts the bridge: the robot is online from here on. */
179
+ /**
180
+ * Cloud accepts the bridge: the robot is online from here on.
181
+ *
182
+ * `protocol` and `bridge` are optional so that a bridge parsing `hello_ok`
183
+ * strictly still parses one from an older cloud. `status` here is never
184
+ * `unsupported`: an unsupported version gets `hello_error`, not this frame.
185
+ */
117
186
  export declare const cloudHelloOk: z.ZodObject<{
118
187
  type: z.ZodLiteral<"hello_ok">;
119
188
  robot_id: z.ZodUUID;
189
+ protocol: z.ZodOptional<z.ZodObject<{
190
+ status: z.ZodEnum<{
191
+ deprecated: "deprecated";
192
+ current: "current";
193
+ }>;
194
+ sunset_at: z.ZodNullable<z.ZodISODate>;
195
+ }, z.core.$strip>>;
196
+ bridge: z.ZodOptional<z.ZodObject<{
197
+ latest_version: z.ZodString;
198
+ }, z.core.$strip>>;
120
199
  }, z.core.$strip>;
121
200
  export type CloudHelloOk = z.infer<typeof cloudHelloOk>;
122
201
  /** Cloud refuses the bridge (bad token, incompatible protocol, ...). */
@@ -129,22 +208,40 @@ export type CloudHelloError = z.infer<typeof cloudHelloError>;
129
208
  /**
130
209
  * One datapoint sample. `timestamp_ms` is the capture time at the bridge —
131
210
  * never the receive time — so clients compute age themselves.
211
+ *
212
+ * Which is exactly why `backfill` has to be on the frame. A replayed sample
213
+ * carries the capture time it had during the outage, so a cloud that measures
214
+ * lag from every arriving frame reads a two-hour disconnect as two hours of
215
+ * lag the moment the bridge reconnects — and reports a healthy link as the
216
+ * worst one it has ever seen. Only the bridge knows which frames came out of
217
+ * its buffer, so only the bridge can say.
132
218
  */
133
219
  export declare const datapointFrame: z.ZodObject<{
134
220
  type: z.ZodLiteral<"datapoint">;
135
221
  slug: z.ZodString;
136
222
  value: z.ZodUnknown;
137
223
  timestamp_ms: z.ZodNumber;
224
+ backfill: z.ZodOptional<z.ZodBoolean>;
138
225
  }, z.core.$strip>;
139
226
  export type DatapointFrame = z.infer<typeof datapointFrame>;
140
227
  /**
141
228
  * Latency probe, cloud → bridge. The cloud sends its own clock in `ts_ms`;
142
229
  * the bridge echoes it back untouched and the cloud derives the round-trip
143
230
  * latency shown as `bridge_state.latency_ms`.
231
+ *
232
+ * Sent every `pingIntervalMs`; the bridge answers with `pong`. Since protocol
233
+ * 3 it also carries what the cloud measured about this link, so the bridge
234
+ * can decide on its low-bandwidth mode with an end-to-end number: the
235
+ * round trip of the last pong, and the datapoint lag — the median over the
236
+ * last five seconds of (receive time − `timestamp_ms`) minus the minimum of
237
+ * the last ten minutes, which cancels the robot's clock offset. `null` until
238
+ * the cloud has a sample. A protocol-2 bridge reads only `ts_ms`.
144
239
  */
145
240
  export declare const cloudPing: z.ZodObject<{
146
241
  type: z.ZodLiteral<"ping">;
147
242
  ts_ms: z.ZodNumber;
243
+ latency_ms: z.ZodNullable<z.ZodNumber>;
244
+ lag_ms: z.ZodNullable<z.ZodNumber>;
148
245
  }, z.core.$strip>;
149
246
  export type CloudPing = z.infer<typeof cloudPing>;
150
247
  /** Immediate bridge answer to a `CloudPing`, `ts_ms` echoed unchanged. */
@@ -153,6 +250,24 @@ export declare const bridgePong: z.ZodObject<{
153
250
  ts_ms: z.ZodNumber;
154
251
  }, z.core.$strip>;
155
252
  export type BridgePong = z.infer<typeof bridgePong>;
253
+ /**
254
+ * The bridge's low-bandwidth mode changed. Sent on every transition and once
255
+ * after `hello_ok`, at tier 0 like the pong: the cloud folds it into
256
+ * `bridge_state.low_bandwidth`, and a frame that waited behind bulk would
257
+ * describe a state that is already over.
258
+ */
259
+ export declare const bridgeLinkMode: z.ZodObject<{
260
+ type: z.ZodLiteral<"link_mode">;
261
+ low_bandwidth: z.ZodBoolean;
262
+ reason: z.ZodEnum<{
263
+ lag: "lag";
264
+ dwell: "dwell";
265
+ forced: "forced";
266
+ recovered: "recovered";
267
+ }>;
268
+ at_ms: z.ZodNumber;
269
+ }, z.core.$strip>;
270
+ export type BridgeLinkMode = z.infer<typeof bridgeLinkMode>;
156
271
  /**
157
272
  * The published configuration, cloud → bridge — the bridge applies the
158
273
  * published version. Sent right after `hello_ok` and again on every publish,
@@ -191,6 +306,7 @@ export declare const cloudConfig: z.ZodObject<{
191
306
  type: z.ZodString;
192
307
  field: z.ZodOptional<z.ZodString>;
193
308
  rate_throttle_hz: z.ZodOptional<z.ZodNumber>;
309
+ low_bandwidth: z.ZodOptional<z.ZodLiteral<"keep">>;
194
310
  description: z.ZodOptional<z.ZodString>;
195
311
  numeric: z.ZodOptional<z.ZodObject<{
196
312
  scale: z.ZodOptional<z.ZodNumber>;
@@ -357,6 +473,23 @@ export declare const cloudConfig: z.ZodObject<{
357
473
  snapshot_interval_seconds: z.ZodNumber;
358
474
  description: z.ZodOptional<z.ZodString>;
359
475
  }, z.core.$strict>>>;
476
+ low_bandwidth: z.ZodOptional<z.ZodObject<{
477
+ mode: z.ZodOptional<z.ZodEnum<{
478
+ auto: "auto";
479
+ on: "on";
480
+ off: "off";
481
+ }>>;
482
+ enter_lag_ms: z.ZodOptional<z.ZodNumber>;
483
+ enter_after_s: z.ZodOptional<z.ZodNumber>;
484
+ exit_lag_ms: z.ZodOptional<z.ZodNumber>;
485
+ exit_after_s: z.ZodOptional<z.ZodNumber>;
486
+ datapoint_max_hz: z.ZodOptional<z.ZodNumber>;
487
+ camera: z.ZodOptional<z.ZodEnum<{
488
+ reduce: "reduce";
489
+ stop: "stop";
490
+ }>>;
491
+ camera_bitrate_kbps: z.ZodOptional<z.ZodNumber>;
492
+ }, z.core.$strict>>;
360
493
  }, z.core.$strict>;
361
494
  }, z.core.$strip>;
362
495
  export type CloudConfig = z.infer<typeof cloudConfig>;
@@ -377,6 +510,7 @@ export declare const bridgeConfigApplied: z.ZodObject<{
377
510
  service: "service";
378
511
  publisher: "publisher";
379
512
  camera: "camera";
513
+ low_bandwidth: "low_bandwidth";
380
514
  }>;
381
515
  code: z.ZodString;
382
516
  message: z.ZodString;
@@ -538,72 +672,17 @@ export type BridgeTypeDefinitions = z.infer<typeof bridgeTypeDefinitions>;
538
672
  /**
539
673
  * The built-in `bridge_state` datapoint every robot has: connection status
540
674
  * plus latency, the basis for offline-aware client UIs.
675
+ *
676
+ * `online` and `latency_ms` are cloud-observed (the socket, the pong);
677
+ * `low_bandwidth` is bridge-reported through `link_mode` and `false` for a
678
+ * bridge that never sends one.
541
679
  */
542
680
  export declare const bridgeState: z.ZodObject<{
543
681
  online: z.ZodBoolean;
544
682
  latency_ms: z.ZodNullable<z.ZodNumber>;
683
+ low_bandwidth: z.ZodBoolean;
545
684
  }, z.core.$strip>;
546
685
  export type BridgeState = z.infer<typeof bridgeState>;
547
- /**
548
- * The built-in `bridge_pressure` datapoint: the bridge's own
549
- * bandwidth-shaping state, sent on the same reserved-slug path as
550
- * `bridge_state` so history, realtime, REST and MCP exposure fall out of the
551
- * ordinary datapoint machinery for free.
552
- */
553
- export declare const bridgePressure: z.ZodObject<{
554
- link: z.ZodObject<{
555
- rate_bps: z.ZodNullable<z.ZodNumber>;
556
- snapshot_max_bytes: z.ZodNumber;
557
- }, z.core.$strip>;
558
- tiers: z.ZodObject<{
559
- '0': z.ZodOptional<z.ZodObject<{
560
- sent: z.ZodNumber;
561
- bytes: z.ZodNumber;
562
- drops: z.ZodNumber;
563
- high_water: z.ZodNumber;
564
- }, z.core.$strip>>;
565
- '1': z.ZodOptional<z.ZodObject<{
566
- sent: z.ZodNumber;
567
- bytes: z.ZodNumber;
568
- drops: z.ZodNumber;
569
- high_water: z.ZodNumber;
570
- }, z.core.$strip>>;
571
- '2': z.ZodOptional<z.ZodObject<{
572
- sent: z.ZodNumber;
573
- bytes: z.ZodNumber;
574
- drops: z.ZodNumber;
575
- high_water: z.ZodNumber;
576
- }, z.core.$strip>>;
577
- '3': z.ZodOptional<z.ZodObject<{
578
- sent: z.ZodNumber;
579
- bytes: z.ZodNumber;
580
- drops: z.ZodNumber;
581
- high_water: z.ZodNumber;
582
- }, z.core.$strip>>;
583
- '4': z.ZodOptional<z.ZodObject<{
584
- sent: z.ZodNumber;
585
- bytes: z.ZodNumber;
586
- drops: z.ZodNumber;
587
- high_water: z.ZodNumber;
588
- }, z.core.$strip>>;
589
- '5': z.ZodOptional<z.ZodObject<{
590
- sent: z.ZodNumber;
591
- bytes: z.ZodNumber;
592
- drops: z.ZodNumber;
593
- high_water: z.ZodNumber;
594
- }, z.core.$strip>>;
595
- }, z.core.$strict>;
596
- video: z.ZodObject<{
597
- active_streams: z.ZodNumber;
598
- bitrate_sum_kbps: z.ZodNumber;
599
- uplink_kbps: z.ZodNullable<z.ZodNumber>;
600
- override_kbps: z.ZodNullable<z.ZodNumber>;
601
- video_budget_kbps: z.ZodNullable<z.ZodNumber>;
602
- reserve_kbps: z.ZodNumber;
603
- }, z.core.$strip>;
604
- }, z.core.$strip>;
605
- export type BridgePressure = z.infer<typeof bridgePressure>;
606
- export declare const PRESSURE_SLUG: "bridge_pressure";
607
686
  /**
608
687
  * The header of a **binary** snapshot frame, bridge → cloud.
609
688
  *
@@ -724,10 +803,10 @@ export declare const bridgeAssetProgress: z.ZodObject<{
724
803
  unresolvable: "unresolvable";
725
804
  upload_failed: "upload_failed";
726
805
  refused: "refused";
727
- too_large: "too_large";
728
806
  }>;
729
807
  details: z.ZodOptional<z.ZodNullable<z.ZodObject<{
730
- limit_bytes: z.ZodNumber;
808
+ store_bytes: z.ZodNumber;
809
+ used_bytes: z.ZodNumber;
731
810
  size_bytes: z.ZodNumber;
732
811
  }, z.core.$strip>>>;
733
812
  }, z.core.$strip>>;
package/dist/protocol.js CHANGED
@@ -7,18 +7,79 @@ import { rosGraph, typeDefinition } from './introspection.js';
7
7
  import { jobState } from './jobs.js';
8
8
  import { rosTypeName } from './common.js';
9
9
  /**
10
- * Bridge <-> cloud protocol, version 2.
10
+ * Bridge <-> cloud protocol, version 3.
11
11
  *
12
- * The version is exchanged in the hello handshake; the cloud refuses an
13
- * incompatible bridge: `protocol_mismatch`, which names both versions and
14
- * reaches the robot's detail view as `last_hello_error`.
12
+ * The version is exchanged in the hello handshake. Since 2026-09 the cloud
13
+ * serves a **window** of versions, not one: every entry of
14
+ * `PROTOCOL_VERSIONS` whose sunset has not passed. A version is deprecated
15
+ * by the cloud release that supersedes it and sunset `PROTOCOL_SUNSET_DAYS`
16
+ * later. Outside the window the cloud refuses with `protocol_mismatch`,
17
+ * which names the window and reaches the robot's detail view as
18
+ * `last_hello_error`.
19
+ *
20
+ * **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
21
+ * sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
22
+ * `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
23
+ * its sunset, and the cloud's protocol-2 adapter owes it two translations on
24
+ * the way in: it drops its pressure datapoints, and it rewrites an
25
+ * `asset_progress` failure of kind `too_large` — a kind protocol 3 no longer
26
+ * has — to `refused` with `details: null`, because a 2.0.0 `bridgeAssetProgress`
27
+ * refuses the frame outright otherwise.
15
28
  *
16
29
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
17
- * beside `message`. The check is `!==`, not a floor, so a bridge that is not
18
- * exactly this version is refused entirely. That is deliberate: a cloud and a
19
- * bridge that disagree about the wire should not pretend otherwise.
30
+ * beside `message`.
31
+ */
32
+ export const PROTOCOL_VERSION = 3;
33
+ /** Days between a version's deprecation and its sunset. */
34
+ export const PROTOCOL_SUNSET_DAYS = 90;
35
+ /**
36
+ * Every protocol version the cloud has served, oldest first. A test keeps
37
+ * exactly one entry current and equal to `PROTOCOL_VERSION`; `test/changelog.test.ts`
38
+ * requires the CHANGELOG's current section to name the newest `bridge_from`
39
+ * and the previous entry's `sunsetOf(...)` date, and `scripts/verify-version-tag.mjs`
40
+ * requires a dated heading for the tag being released.
41
+ */
42
+ export const PROTOCOL_VERSIONS = [
43
+ { version: 2, bridge_from: '3.0.0', deprecated_at: '2026-09-22' },
44
+ { version: 3, bridge_from: '4.0.0', deprecated_at: null },
45
+ ];
46
+ /** The newest bridge package. The cloud mails organisations still below it. */
47
+ export const LATEST_BRIDGE_VERSION = '4.0.0';
48
+ const DAY_MS = 24 * 60 * 60 * 1000;
49
+ function isoDate(date) {
50
+ return date.toISOString().slice(0, 10);
51
+ }
52
+ export function sunsetOf(entry) {
53
+ if (entry.deprecated_at === null)
54
+ return null;
55
+ return isoDate(new Date(Date.parse(entry.deprecated_at + 'T00:00:00Z') + PROTOCOL_SUNSET_DAYS * DAY_MS));
56
+ }
57
+ /**
58
+ * The window rule itself: `protocolStatus` and `minimumProtocolVersion` are
59
+ * this function over `PROTOCOL_VERSIONS`, and the cloud calls it directly.
60
+ *
61
+ * The table is a parameter because callers pass one with a deprecated entry —
62
+ * tests, and any caller reasoning about a sunset. The real table has none
63
+ * until the first bump, so a hard-coded `PROTOCOL_VERSIONS` would leave the
64
+ * deprecated and unsupported branches unreachable.
20
65
  */
21
- export const PROTOCOL_VERSION = 2;
66
+ export function statusFromTable(table, version, today) {
67
+ const entry = table.find((candidate) => candidate.version === version);
68
+ if (!entry)
69
+ return { status: 'unsupported', sunset_at: null };
70
+ const sunset = sunsetOf(entry);
71
+ if (sunset === null)
72
+ return { status: 'current', sunset_at: null };
73
+ return { status: isoDate(today) < sunset ? 'deprecated' : 'unsupported', sunset_at: sunset };
74
+ }
75
+ export function protocolStatus(version, today = new Date()) {
76
+ return statusFromTable(PROTOCOL_VERSIONS, version, today);
77
+ }
78
+ /** The lowest version still inside its window today. */
79
+ export function minimumProtocolVersion(today = new Date()) {
80
+ const alive = PROTOCOL_VERSIONS.filter((entry) => statusFromTable(PROTOCOL_VERSIONS, entry.version, today).status !== 'unsupported');
81
+ return alive[0]?.version ?? PROTOCOL_VERSION;
82
+ }
22
83
  /**
23
84
  * The bridge socket close code for "this robot no longer exists".
24
85
  *
@@ -30,6 +91,20 @@ export const PROTOCOL_VERSION = 2;
30
91
  * more informative than "connection closed".
31
92
  */
32
93
  export const CLOSE_ROBOT_DELETED = 4004;
94
+ /**
95
+ * The bridge socket close code for "the token you connected with is gone".
96
+ *
97
+ * The cloud closes a robot's live socket with this after the robot's token was
98
+ * rotated. A 4.0.0 bridge reads it the way it reads `invalid_token` — stop,
99
+ * exit 2 — because the secret it holds is no longer a secret anyone accepts,
100
+ * and no amount of reconnecting produces the new one. A 3.x bridge does not
101
+ * know the code, reconnects, and is refused at hello; that ends the same way,
102
+ * one round trip later.
103
+ *
104
+ * Its own code rather than `CLOSE_ROBOT_DELETED`, which would tell an operator
105
+ * their robot had been deleted when it very much still exists.
106
+ */
107
+ export const CLOSE_TOKEN_ROTATED = 4005;
33
108
  /**
34
109
  * How long a command waits for its answer when the caller names no patience
35
110
  * of its own.
@@ -120,10 +195,35 @@ export const bridgeHello = z.object({
120
195
  */
121
196
  active_jobs: z.array(activeJob).max(500).default([]),
122
197
  });
123
- /** Cloud accepts the bridge: the robot is online from here on. */
198
+ /**
199
+ * Cloud accepts the bridge: the robot is online from here on.
200
+ *
201
+ * `protocol` and `bridge` are optional so that a bridge parsing `hello_ok`
202
+ * strictly still parses one from an older cloud. `status` here is never
203
+ * `unsupported`: an unsupported version gets `hello_error`, not this frame.
204
+ */
124
205
  export const cloudHelloOk = z.object({
125
206
  type: z.literal('hello_ok'),
126
207
  robot_id: z.uuid(),
208
+ protocol: z
209
+ .object({
210
+ status: z.enum(['current', 'deprecated']).meta({
211
+ description: '`current` or `deprecated` — never `unsupported`, which is a `hello_error`.',
212
+ }),
213
+ sunset_at: z.iso.date().nullable().meta({
214
+ description: 'ISO date a deprecated version stops being served; `null` when current.',
215
+ }),
216
+ })
217
+ .optional()
218
+ .meta({ description: "The cloud's verdict on the announced protocol version; absent from an older cloud." }),
219
+ bridge: z
220
+ .object({
221
+ latest_version: z.string().min(1).meta({
222
+ description: "The newest published fleetless-bridge package version, for the bridge's own upgrade hint.",
223
+ }),
224
+ })
225
+ .optional()
226
+ .meta({ description: 'What the cloud knows about bridge packages; absent from an older cloud.' }),
127
227
  });
128
228
  /** Cloud refuses the bridge (bad token, incompatible protocol, ...). */
129
229
  export const cloudHelloError = z.object({
@@ -134,27 +234,57 @@ export const cloudHelloError = z.object({
134
234
  /**
135
235
  * One datapoint sample. `timestamp_ms` is the capture time at the bridge —
136
236
  * never the receive time — so clients compute age themselves.
237
+ *
238
+ * Which is exactly why `backfill` has to be on the frame. A replayed sample
239
+ * carries the capture time it had during the outage, so a cloud that measures
240
+ * lag from every arriving frame reads a two-hour disconnect as two hours of
241
+ * lag the moment the bridge reconnects — and reports a healthy link as the
242
+ * worst one it has ever seen. Only the bridge knows which frames came out of
243
+ * its buffer, so only the bridge can say.
137
244
  */
138
245
  export const datapointFrame = z.object({
139
246
  type: z.literal('datapoint'),
140
247
  slug,
141
248
  value: z.unknown(),
142
249
  timestamp_ms: z.number().int().nonnegative(),
250
+ backfill: z.boolean().optional().meta({ description: 'true when the sample was captured while the bridge was disconnected and is being replayed after the reconnect. The cloud keeps such a sample out of its lag measure; absent means live.' }),
143
251
  });
144
252
  /**
145
253
  * Latency probe, cloud → bridge. The cloud sends its own clock in `ts_ms`;
146
254
  * the bridge echoes it back untouched and the cloud derives the round-trip
147
255
  * latency shown as `bridge_state.latency_ms`.
256
+ *
257
+ * Sent every `pingIntervalMs`; the bridge answers with `pong`. Since protocol
258
+ * 3 it also carries what the cloud measured about this link, so the bridge
259
+ * can decide on its low-bandwidth mode with an end-to-end number: the
260
+ * round trip of the last pong, and the datapoint lag — the median over the
261
+ * last five seconds of (receive time − `timestamp_ms`) minus the minimum of
262
+ * the last ten minutes, which cancels the robot's clock offset. `null` until
263
+ * the cloud has a sample. A protocol-2 bridge reads only `ts_ms`.
148
264
  */
149
265
  export const cloudPing = z.object({
150
266
  type: z.literal('ping'),
151
267
  ts_ms: z.number().int().nonnegative(),
268
+ latency_ms: z.number().nonnegative().nullable().meta({ description: 'Round trip of the last pong in milliseconds; null before the first.' }),
269
+ lag_ms: z.number().nonnegative().nullable().meta({ description: 'Datapoint lag over the link: median of the last five seconds minus the ten-minute minimum, in milliseconds; null until a sample exists, and null again whenever no live sample arrived in the last five seconds, because a stale median would be a lie.' }),
152
270
  });
153
271
  /** Immediate bridge answer to a `CloudPing`, `ts_ms` echoed unchanged. */
154
272
  export const bridgePong = z.object({
155
273
  type: z.literal('pong'),
156
274
  ts_ms: z.number().int().nonnegative(),
157
275
  });
276
+ /**
277
+ * The bridge's low-bandwidth mode changed. Sent on every transition and once
278
+ * after `hello_ok`, at tier 0 like the pong: the cloud folds it into
279
+ * `bridge_state.low_bandwidth`, and a frame that waited behind bulk would
280
+ * describe a state that is already over.
281
+ */
282
+ export const bridgeLinkMode = z.object({
283
+ type: z.literal('link_mode'),
284
+ low_bandwidth: z.boolean().meta({ description: 'Whether the mode is active after this transition.' }),
285
+ reason: z.enum(['lag', 'dwell', 'forced', 'recovered']).meta({ description: '`lag`: the cloud-measured lag crossed the threshold; `dwell`: the bridge-measured queue dwell did; `forced`: `mode: on` or `off`; `recovered`: both measures stayed at or below the exit threshold.' }),
286
+ at_ms: z.number().int().nonnegative().meta({ description: 'Bridge time of the transition, epoch milliseconds.' }),
287
+ });
158
288
  /**
159
289
  * The published configuration, cloud → bridge — the bridge applies the
160
290
  * published version. Sent right after `hello_ok` and again on every publish,
@@ -350,89 +480,16 @@ export const bridgeTypeDefinitions = z.object({
350
480
  /**
351
481
  * The built-in `bridge_state` datapoint every robot has: connection status
352
482
  * plus latency, the basis for offline-aware client UIs.
483
+ *
484
+ * `online` and `latency_ms` are cloud-observed (the socket, the pong);
485
+ * `low_bandwidth` is bridge-reported through `link_mode` and `false` for a
486
+ * bridge that never sends one.
353
487
  */
354
488
  export const bridgeState = z.object({
355
489
  online: z.boolean(),
356
490
  latency_ms: z.number().nonnegative().nullable(),
491
+ low_bandwidth: z.boolean().meta({ description: 'Whether the bridge is in its low-bandwidth mode: datapoints capped, cameras reduced or stopped. Bridge-reported.' }),
357
492
  });
358
- /** One tier's counters, `tiers` below carries six of these under string keys. */
359
- const bridgePressureTier = z.object({
360
- sent: z.number().int().nonnegative(),
361
- bytes: z.number().int().nonnegative(),
362
- drops: z.number().int().nonnegative(),
363
- high_water: z.number().int().nonnegative(),
364
- });
365
- /**
366
- * The built-in `bridge_pressure` datapoint: the bridge's own
367
- * bandwidth-shaping state, sent on the same reserved-slug path as
368
- * `bridge_state` so history, realtime, REST and MCP exposure fall out of the
369
- * ordinary datapoint machinery for free.
370
- */
371
- export const bridgePressure = z.object({
372
- link: z.object({
373
- /** bytes/s the socket demonstrably drains, from sends >= 64 KiB
374
- * only; null until the first large send of the session. */
375
- rate_bps: z.number().nonnegative().nullable(),
376
- /**
377
- * the byte target snapshots are currently encoded to fit.
378
- *
379
- * `.nonnegative()`, not `.positive()`: the target is derived from
380
- * `rate_bps`, and a link measured below 0.5 B/s floors to 0 here. A
381
- * schema that rejects 0 does not prevent that link — it only makes the
382
- * frame reporting it unparseable, and a console that cannot parse a
383
- * pressure frame shows "no feed", i.e. reports a struggling robot as an
384
- * *old* one. Zero is a legitimate reading and says something true.
385
- */
386
- snapshot_max_bytes: z.number().int().nonnegative(),
387
- }),
388
- /**
389
- * String keys "0".."5" because JSON has no integer keys. Counters are
390
- * cumulative per session and reset on reconnect; clients window by
391
- * differencing two samples.
392
- *
393
- * **What this schema does not decide:** it does not guarantee all six
394
- * keys are present (`z.record` over the six literals is exhaustive in
395
- * zod 4 — tested here, it required every key and rejected none, the
396
- * opposite of what a partial sample needs — so this is a
397
- * `.strictObject().partial()` over the same six literal keys instead, a
398
- * deliberate deviation from the originally sketched `z.record` shape with
399
- * the same runtime behaviour). A missing tier key reads as zeros; the
400
- * schema names what it cannot decide rather than implying a completeness
401
- * it cannot check.
402
- */
403
- tiers: z
404
- .strictObject({
405
- '0': bridgePressureTier,
406
- '1': bridgePressureTier,
407
- '2': bridgePressureTier,
408
- '3': bridgePressureTier,
409
- '4': bridgePressureTier,
410
- '5': bridgePressureTier,
411
- })
412
- .partial(),
413
- video: z.object({
414
- active_streams: z.number().int().nonnegative(),
415
- bitrate_sum_kbps: z.number().int().nonnegative(),
416
- /**
417
- * The uplink budget the bridge was configured with
418
- * (`FLEETLESS_UPLINK_KBPS`), or `null` when none was set.
419
- *
420
- * `.nonnegative()`, not `.positive()`: `FLEETLESS_UPLINK_KBPS=0` is a
421
- * documented setting meaning "no video budget at all", and the bridge
422
- * emits that 0 verbatim. `.positive()` made every frame from such a
423
- * robot fail the console's `safeParse`, which renders an unparseable
424
- * frame as "no pressure feed" — so the one robot that had *deliberately*
425
- * turned video off was the one diagnosed as running a bridge too old to
426
- * report pressure. A value the producer legitimately sends must parse;
427
- * `null` is the only "not set" this field has.
428
- */
429
- uplink_kbps: z.number().int().nonnegative().nullable(),
430
- override_kbps: z.number().int().nonnegative().nullable(),
431
- video_budget_kbps: z.number().int().nonnegative().nullable(),
432
- reserve_kbps: z.number().int().nonnegative(),
433
- }),
434
- });
435
- export const PRESSURE_SLUG = 'bridge_pressure';
436
493
  /* ------------------------------------------------------------------------
437
494
  * Cameras.
438
495
  */