@fleetless/contracts 1.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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +88 -0
- package/artifacts/constants.json +24 -0
- package/artifacts/openapi.json +17219 -0
- package/artifacts/routes.json +4605 -0
- package/artifacts/schema/accept-team-invite-request.schema.json +22 -0
- package/artifacts/schema/action-config.schema.json +198 -0
- package/artifacts/schema/alert-list-response.schema.json +172 -0
- package/artifacts/schema/api-error.schema.json +20 -0
- package/artifacts/schema/app-auth-config.schema.json +106 -0
- package/artifacts/schema/app-invitation-list-response.schema.json +57 -0
- package/artifacts/schema/app-invitation.schema.json +69 -0
- package/artifacts/schema/app-list-response.schema.json +82 -0
- package/artifacts/schema/app-mail-template-list-response.schema.json +68 -0
- package/artifacts/schema/app-mail-template.schema.json +54 -0
- package/artifacts/schema/app-oidc-provider-list-response.schema.json +93 -0
- package/artifacts/schema/app-oidc-provider.schema.json +80 -0
- package/artifacts/schema/app-user-list-response.schema.json +111 -0
- package/artifacts/schema/app-user.schema.json +98 -0
- package/artifacts/schema/app.schema.json +69 -0
- package/artifacts/schema/apply-error.schema.json +41 -0
- package/artifacts/schema/asset-list-response.schema.json +288 -0
- package/artifacts/schema/asset-sync-request.schema.json +17 -0
- package/artifacts/schema/asset-sync-response.schema.json +16 -0
- package/artifacts/schema/asset-sync-status.schema.json +136 -0
- package/artifacts/schema/asset.schema.json +68 -0
- package/artifacts/schema/audit-actor.schema.json +32 -0
- package/artifacts/schema/audit-event.schema.json +119 -0
- package/artifacts/schema/audit-list-response.schema.json +144 -0
- package/artifacts/schema/audit-query.schema.json +79 -0
- package/artifacts/schema/auth-error.schema.json +23 -0
- package/artifacts/schema/auth-me-response.schema.json +99 -0
- package/artifacts/schema/auth-ok.schema.json +115 -0
- package/artifacts/schema/authorization-server-metadata.schema.json +80 -0
- package/artifacts/schema/bridge-asset-progress.schema.json +99 -0
- package/artifacts/schema/bridge-assets-available.schema.json +25 -0
- package/artifacts/schema/bridge-camera-state.schema.json +78 -0
- package/artifacts/schema/bridge-config-applied.schema.json +67 -0
- package/artifacts/schema/bridge-hello.schema.json +65 -0
- package/artifacts/schema/bridge-introspect.schema.json +114 -0
- package/artifacts/schema/bridge-job-lost.schema.json +22 -0
- package/artifacts/schema/bridge-job-update.schema.json +100 -0
- package/artifacts/schema/bridge-pong.schema.json +19 -0
- package/artifacts/schema/bridge-pressure.schema.json +292 -0
- package/artifacts/schema/bridge-state.schema.json +24 -0
- package/artifacts/schema/bridge-type-definitions.schema.json +169 -0
- package/artifacts/schema/busy-details.schema.json +115 -0
- package/artifacts/schema/camera-descriptor.schema.json +45 -0
- package/artifacts/schema/camera-list-response.schema.json +58 -0
- package/artifacts/schema/camera-source.schema.json +240 -0
- package/artifacts/schema/cancel-request.schema.json +20 -0
- package/artifacts/schema/client-accept-invitation-request.schema.json +35 -0
- package/artifacts/schema/client-auth.schema.json +18 -0
- package/artifacts/schema/client-cancel.schema.json +45 -0
- package/artifacts/schema/client-identity.schema.json +103 -0
- package/artifacts/schema/client-invoke.schema.json +45 -0
- package/artifacts/schema/client-login-request.schema.json +29 -0
- package/artifacts/schema/client-logout-request.schema.json +14 -0
- package/artifacts/schema/client-mcp-interaction-decision-response.schema.json +15 -0
- package/artifacts/schema/client-mcp-interaction.schema.json +59 -0
- package/artifacts/schema/client-oidc-callback-query.schema.json +28 -0
- package/artifacts/schema/client-oidc-exchange-request.schema.json +21 -0
- package/artifacts/schema/client-oidc-start-query.schema.json +36 -0
- package/artifacts/schema/client-password-reset-confirm-request.schema.json +22 -0
- package/artifacts/schema/client-password-reset-request.schema.json +24 -0
- package/artifacts/schema/client-provider-list-query.schema.json +17 -0
- package/artifacts/schema/client-provider-list-response.schema.json +34 -0
- package/artifacts/schema/client-publish.schema.json +40 -0
- package/artifacts/schema/client-refresh-request.schema.json +14 -0
- package/artifacts/schema/client-register-request.schema.json +44 -0
- package/artifacts/schema/client-resend-verification-request.schema.json +24 -0
- package/artifacts/schema/client-subscribe.schema.json +43 -0
- package/artifacts/schema/client-unsubscribe.schema.json +26 -0
- package/artifacts/schema/client-verify-email-request.schema.json +15 -0
- package/artifacts/schema/cloud-asset-request.schema.json +37 -0
- package/artifacts/schema/cloud-camera-start.schema.json +41 -0
- package/artifacts/schema/cloud-camera-stop.schema.json +26 -0
- package/artifacts/schema/cloud-cancel.schema.json +33 -0
- package/artifacts/schema/cloud-config.schema.json +1635 -0
- package/artifacts/schema/cloud-hello-error.schema.json +23 -0
- package/artifacts/schema/cloud-hello-ok.schema.json +19 -0
- package/artifacts/schema/cloud-introspect-request.schema.json +19 -0
- package/artifacts/schema/cloud-invoke.schema.json +40 -0
- package/artifacts/schema/cloud-ping.schema.json +19 -0
- package/artifacts/schema/cloud-publish.schema.json +28 -0
- package/artifacts/schema/cloud-type-request.schema.json +30 -0
- package/artifacts/schema/command-result.schema.json +175 -0
- package/artifacts/schema/config-draft-response.schema.json +1695 -0
- package/artifacts/schema/config-state.schema.json +124 -0
- package/artifacts/schema/config-version-response.schema.json +1641 -0
- package/artifacts/schema/config-versions-response.schema.json +33 -0
- package/artifacts/schema/create-app-invitation-request.schema.json +40 -0
- package/artifacts/schema/create-app-oidc-provider-request.schema.json +70 -0
- package/artifacts/schema/create-app-request.schema.json +30 -0
- package/artifacts/schema/create-app-user-request.schema.json +42 -0
- package/artifacts/schema/create-robot-request.schema.json +14 -0
- package/artifacts/schema/create-robot-response.schema.json +44 -0
- package/artifacts/schema/create-server-key-response.schema.json +65 -0
- package/artifacts/schema/create-team-invite-request.schema.json +43 -0
- package/artifacts/schema/datapoint-alert-row.schema.json +160 -0
- package/artifacts/schema/datapoint-config.schema.json +366 -0
- package/artifacts/schema/datapoint-display.schema.json +31 -0
- package/artifacts/schema/datapoint-event.schema.json +34 -0
- package/artifacts/schema/datapoint-frame.schema.json +28 -0
- package/artifacts/schema/datapoint-list-response.schema.json +61 -0
- package/artifacts/schema/datapoint-value.schema.json +28 -0
- package/artifacts/schema/developer-login-request.schema.json +19 -0
- package/artifacts/schema/dynamic-client-registration-request.schema.json +60 -0
- package/artifacts/schema/dynamic-client-registration-response.schema.json +68 -0
- package/artifacts/schema/error-frame.schema.json +23 -0
- package/artifacts/schema/exposure-counts.schema.json +39 -0
- package/artifacts/schema/exposure-list-response.schema.json +43 -0
- package/artifacts/schema/fetch-types-request.schema.json +19 -0
- package/artifacts/schema/fetch-types-response.schema.json +163 -0
- package/artifacts/schema/fleetless-user-list-response.schema.json +73 -0
- package/artifacts/schema/fleetless-user.schema.json +60 -0
- package/artifacts/schema/history-buckets-response.schema.json +79 -0
- package/artifacts/schema/history-query.schema.json +58 -0
- package/artifacts/schema/history-response.schema.json +150 -0
- package/artifacts/schema/history-samples-response.schema.json +68 -0
- package/artifacts/schema/introspection-response.schema.json +118 -0
- package/artifacts/schema/invoke-or-service-response.schema.json +141 -0
- package/artifacts/schema/invoke-request.schema.json +23 -0
- package/artifacts/schema/invoke-response.schema.json +125 -0
- package/artifacts/schema/job-actor.schema.json +34 -0
- package/artifacts/schema/job-event.schema.json +158 -0
- package/artifacts/schema/job-response.schema.json +123 -0
- package/artifacts/schema/job-run-list-response.schema.json +222 -0
- package/artifacts/schema/job-run-query.schema.json +95 -0
- package/artifacts/schema/job-run-summary-query.schema.json +23 -0
- package/artifacts/schema/job-run-summary.schema.json +33 -0
- package/artifacts/schema/job-run.schema.json +195 -0
- package/artifacts/schema/job-state.schema.json +11 -0
- package/artifacts/schema/job.schema.json +106 -0
- package/artifacts/schema/latency-bucket.schema.json +63 -0
- package/artifacts/schema/live-session-response.schema.json +41 -0
- package/artifacts/schema/mail-outcome.schema.json +20 -0
- package/artifacts/schema/mail-template-preview-request.schema.json +35 -0
- package/artifacts/schema/mail-template-preview-response.schema.json +31 -0
- package/artifacts/schema/mail-template-problem-details.schema.json +24 -0
- package/artifacts/schema/mcp-consent-grant-list-response.schema.json +52 -0
- package/artifacts/schema/mcp-consent-grant.schema.json +39 -0
- package/artifacts/schema/mcp-robot-datasheet.schema.json +115 -0
- package/artifacts/schema/mcp-role-preview-response.schema.json +134 -0
- package/artifacts/schema/missing-asset-query.schema.json +11 -0
- package/artifacts/schema/oauth-authorize-query.schema.json +47 -0
- package/artifacts/schema/oauth-redirect-response.schema.json +15 -0
- package/artifacts/schema/oauth-token-request.schema.json +47 -0
- package/artifacts/schema/oauth-token-response.schema.json +38 -0
- package/artifacts/schema/org-alerts-query.schema.json +15 -0
- package/artifacts/schema/org-event-dropped.schema.json +26 -0
- package/artifacts/schema/org-event-replay.schema.json +97 -0
- package/artifacts/schema/org-event-subscribe.schema.json +14 -0
- package/artifacts/schema/org-event-unsubscribe.schema.json +14 -0
- package/artifacts/schema/org-event.schema.json +75 -0
- package/artifacts/schema/org-firing-alerts-response.schema.json +178 -0
- package/artifacts/schema/org-health-query.schema.json +13 -0
- package/artifacts/schema/org-latency-query.schema.json +42 -0
- package/artifacts/schema/org-latency-response.schema.json +124 -0
- package/artifacts/schema/org-quota-usage-counts.schema.json +42 -0
- package/artifacts/schema/org-quota-usage.schema.json +102 -0
- package/artifacts/schema/org-quotas.schema.json +51 -0
- package/artifacts/schema/org-usage-query.schema.json +19 -0
- package/artifacts/schema/org-usage-response.schema.json +77 -0
- package/artifacts/schema/org.schema.json +30 -0
- package/artifacts/schema/parameter-invalid-details.schema.json +37 -0
- package/artifacts/schema/parameter-spec.schema.json +120 -0
- package/artifacts/schema/parameter-violation.schema.json +24 -0
- package/artifacts/schema/password-change-request.schema.json +21 -0
- package/artifacts/schema/password-reset-confirm.schema.json +19 -0
- package/artifacts/schema/password-reset-request.schema.json +14 -0
- package/artifacts/schema/patch-app-oidc-provider-request.schema.json +50 -0
- package/artifacts/schema/patch-app-user-request.schema.json +34 -0
- package/artifacts/schema/patch-auth-me-request.schema.json +22 -0
- package/artifacts/schema/patch-fleetless-user-request.schema.json +20 -0
- package/artifacts/schema/patch-org-request.schema.json +15 -0
- package/artifacts/schema/patch-org-response.schema.json +40 -0
- package/artifacts/schema/patch-robot-request.schema.json +15 -0
- package/artifacts/schema/patch-robot-response.schema.json +40 -0
- package/artifacts/schema/pending-team-invite-list-response.schema.json +52 -0
- package/artifacts/schema/pending-team-invite.schema.json +39 -0
- package/artifacts/schema/protected-resource-metadata.schema.json +41 -0
- package/artifacts/schema/publish-config-response.schema.json +21 -0
- package/artifacts/schema/publish-request.schema.json +17 -0
- package/artifacts/schema/publisher-config.schema.json +285 -0
- package/artifacts/schema/put-app-auth-config-request.schema.json +93 -0
- package/artifacts/schema/put-app-mail-template-request.schema.json +35 -0
- package/artifacts/schema/put-config-draft-request.schema.json +13 -0
- package/artifacts/schema/put-datapoint-display-request.schema.json +31 -0
- package/artifacts/schema/put-robot-details-request.schema.json +41 -0
- package/artifacts/schema/put-robot-details-response.schema.json +43 -0
- package/artifacts/schema/rate-limit-details.schema.json +15 -0
- package/artifacts/schema/refresh-request.schema.json +13 -0
- package/artifacts/schema/release-live-query.schema.json +13 -0
- package/artifacts/schema/rename-slug-request.schema.json +23 -0
- package/artifacts/schema/rename-slug-response.schema.json +24 -0
- package/artifacts/schema/resource-health-event.schema.json +72 -0
- package/artifacts/schema/resource-health-list-response.schema.json +80 -0
- package/artifacts/schema/resource-health-state.schema.json +68 -0
- package/artifacts/schema/robot-config-doc.schema.json +1616 -0
- package/artifacts/schema/robot-delete-query.schema.json +12 -0
- package/artifacts/schema/robot-deletion-summary.schema.json +63 -0
- package/artifacts/schema/robot-detail-response.schema.json +262 -0
- package/artifacts/schema/robot-details-doc.schema.json +33 -0
- package/artifacts/schema/robot-jobs-response.schema.json +119 -0
- package/artifacts/schema/robot-latency-series.schema.json +81 -0
- package/artifacts/schema/robot-list-item.schema.json +94 -0
- package/artifacts/schema/robot-list-response.schema.json +106 -0
- package/artifacts/schema/robot.schema.json +30 -0
- package/artifacts/schema/role-list-response.schema.json +48 -0
- package/artifacts/schema/role-permissions.schema.json +61 -0
- package/artifacts/schema/role.schema.json +35 -0
- package/artifacts/schema/ros-graph.schema.json +99 -0
- package/artifacts/schema/server-key-list-response.schema.json +64 -0
- package/artifacts/schema/server-key.schema.json +51 -0
- package/artifacts/schema/service-call-response.schema.json +13 -0
- package/artifacts/schema/service-config.schema.json +198 -0
- package/artifacts/schema/session-tokens.schema.json +28 -0
- package/artifacts/schema/sign-up-request.schema.json +26 -0
- package/artifacts/schema/sign-up-response.schema.json +127 -0
- package/artifacts/schema/slug-usage-response.schema.json +32 -0
- package/artifacts/schema/snapshot-header.schema.json +44 -0
- package/artifacts/schema/snapshot-meta-response.schema.json +85 -0
- package/artifacts/schema/subscribe-error.schema.json +31 -0
- package/artifacts/schema/team-invite.schema.json +57 -0
- package/artifacts/schema/tier-change-request.schema.json +17 -0
- package/artifacts/schema/type-definition.schema.json +144 -0
- package/artifacts/schema/types-response.schema.json +156 -0
- package/artifacts/schema/update-app-request.schema.json +32 -0
- package/artifacts/schema/urdf-completeness.schema.json +50 -0
- package/artifacts/schema/validation-issue.schema.json +43 -0
- package/artifacts/schema/waitlist-request.schema.json +15 -0
- package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-assets-available.schema.json +26 -0
- package/artifacts/schema-outgoing/bridge-camera-state.schema.json +80 -0
- package/artifacts/schema-outgoing/bridge-config-applied.schema.json +69 -0
- package/artifacts/schema-outgoing/bridge-hello.schema.json +68 -0
- package/artifacts/schema-outgoing/bridge-introspect.schema.json +119 -0
- package/artifacts/schema-outgoing/bridge-job-lost.schema.json +23 -0
- package/artifacts/schema-outgoing/bridge-job-update.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-pong.schema.json +20 -0
- package/artifacts/schema-outgoing/bridge-type-definitions.schema.json +174 -0
- package/artifacts/schema-outgoing/datapoint-frame.schema.json +29 -0
- package/artifacts/schema-outgoing/snapshot-header.schema.json +45 -0
- package/dist/alerts.d.ts +255 -0
- package/dist/alerts.js +193 -0
- package/dist/app-users.d.ts +606 -0
- package/dist/app-users.js +696 -0
- package/dist/apps.d.ts +175 -0
- package/dist/apps.js +267 -0
- package/dist/assets.d.ts +434 -0
- package/dist/assets.js +546 -0
- package/dist/audit.d.ts +129 -0
- package/dist/audit.js +238 -0
- package/dist/client-auth.d.ts +409 -0
- package/dist/client-auth.js +487 -0
- package/dist/common.d.ts +186 -0
- package/dist/common.js +199 -0
- package/dist/config-issues.d.ts +175 -0
- package/dist/config-issues.js +339 -0
- package/dist/config.d.ts +862 -0
- package/dist/config.js +1988 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +786 -0
- package/dist/identity.d.ts +549 -0
- package/dist/identity.js +503 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/introspection.d.ts +99 -0
- package/dist/introspection.js +97 -0
- package/dist/jobs.d.ts +334 -0
- package/dist/jobs.js +345 -0
- package/dist/mcp.d.ts +239 -0
- package/dist/mcp.js +153 -0
- package/dist/oauth.d.ts +344 -0
- package/dist/oauth.js +488 -0
- package/dist/protocol.d.ts +781 -0
- package/dist/protocol.js +715 -0
- package/dist/realtime.d.ts +494 -0
- package/dist/realtime.js +512 -0
- package/dist/rest.d.ts +1989 -0
- package/dist/rest.js +1963 -0
- package/dist/routes.d.ts +94 -0
- package/dist/routes.js +2298 -0
- package/package.json +61 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { assetFailure } from './assets.js';
|
|
4
|
+
import { applyError, slug } from './common.js';
|
|
5
|
+
import { robotConfigDoc } from './config.js';
|
|
6
|
+
import { rosGraph, typeDefinition } from './introspection.js';
|
|
7
|
+
import { jobState } from './jobs.js';
|
|
8
|
+
import { rosTypeName } from './common.js';
|
|
9
|
+
/**
|
|
10
|
+
* Bridge <-> cloud protocol, version 2.
|
|
11
|
+
*
|
|
12
|
+
* The version is exchanged in the hello handshake; the cloud refuses an
|
|
13
|
+
* incompatible bridge with a clear message (spec §5) — `ws/bridge.ts`'s
|
|
14
|
+
* `protocol_mismatch`, which names both versions and lands on the robot
|
|
15
|
+
* detail page as `last_hello_error`.
|
|
16
|
+
*
|
|
17
|
+
* **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
|
|
18
|
+
* beside `message`. The check is `!==`, not a floor, so a bridge that is not
|
|
19
|
+
* exactly this version is refused entirely. That is deliberate: a cloud and a
|
|
20
|
+
* bridge that disagree about the wire should not pretend otherwise.
|
|
21
|
+
*/
|
|
22
|
+
export const PROTOCOL_VERSION = 2;
|
|
23
|
+
/**
|
|
24
|
+
* The bridge socket close code for "this robot no longer exists" (W6a).
|
|
25
|
+
*
|
|
26
|
+
* Deliberately distinct from the auth failures: a deleted robot must **stop**,
|
|
27
|
+
* and a token that was valid a second ago is indistinguishable from one that
|
|
28
|
+
* was revoked unless the cloud says which. Without its own code the bridge
|
|
29
|
+
* reconnects forever against a robot that will never come back — a permanent
|
|
30
|
+
* load on the cloud, and a robot on someone's shelf whose logs say nothing
|
|
31
|
+
* more informative than "connection closed".
|
|
32
|
+
*/
|
|
33
|
+
export const CLOSE_ROBOT_DELETED = 4004;
|
|
34
|
+
/**
|
|
35
|
+
* How long a command waits for its answer when the caller names no patience
|
|
36
|
+
* of its own (W6b).
|
|
37
|
+
*
|
|
38
|
+
* 15 s, which is what both halves already used independently: the cloud's
|
|
39
|
+
* `commandTimeoutMs` and the bridge's `GOAL_ACCEPT_TIMEOUT_S`. That they
|
|
40
|
+
* agreed was a coincidence of two separate decisions, and neither side could
|
|
41
|
+
* be told otherwise for a single call. Naming the number once, here, is what
|
|
42
|
+
* makes it one number rather than two that happen to match.
|
|
43
|
+
*
|
|
44
|
+
* A caller who knows their robot's work takes longer says so per call. A
|
|
45
|
+
* caller who says nothing gets exactly today's behaviour — which is the point
|
|
46
|
+
* of picking today's number as the default rather than a nicer one.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_PATIENCE_MS = 15_000;
|
|
49
|
+
/**
|
|
50
|
+
* The longest patience a caller may ask for.
|
|
51
|
+
*
|
|
52
|
+
* A waiting REST request is a held-open connection, and there is **no rate
|
|
53
|
+
* limiting** on this platform until W8 — so an unbounded `patience_ms` is an
|
|
54
|
+
* unauthenticated way to pin the cloud's sockets open. Two minutes is long
|
|
55
|
+
* enough for the robot work anybody has described (a planner, a docking
|
|
56
|
+
* manoeuvre, an arm trajectory) and short enough that a thousand of them is
|
|
57
|
+
* still a bounded amount of cloud.
|
|
58
|
+
*
|
|
59
|
+
* Raising it is a W8 conversation, after rate limiting exists — not a
|
|
60
|
+
* one-line change here.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_PATIENCE_MS = 120_000;
|
|
63
|
+
/**
|
|
64
|
+
* The shortest patience a caller may ask for.
|
|
65
|
+
*
|
|
66
|
+
* A floor exists because **impatience reaches the robot**. Measured in W6b's
|
|
67
|
+
* review: `patience_ms: 1` on an action makes the bridge report `goal_timeout`
|
|
68
|
+
* and then issue a *corrective cancel* against a goal the action server
|
|
69
|
+
* accepts a moment later — so a caller who asks for an unreachable deadline
|
|
70
|
+
* does not merely get an error, they cause a cancellation on the machine.
|
|
71
|
+
* Repeatable, and on a platform with no rate limiting until W8.
|
|
72
|
+
*
|
|
73
|
+
* One second, because it has to be longer than a goal-acceptance round trip on
|
|
74
|
+
* a healthy robot and shorter than any wait a human would call patient. It is
|
|
75
|
+
* a guard against a number that cannot be satisfied, not a policy about how
|
|
76
|
+
* long work takes — `MAX_PATIENCE_MS` is the end that bounds the platform,
|
|
77
|
+
* this end bounds what the caller can do to the robot.
|
|
78
|
+
*/
|
|
79
|
+
export const MIN_PATIENCE_MS = 1_000;
|
|
80
|
+
/** Re-exported so consumers keep importing wire names from one place. */
|
|
81
|
+
export { slug } from './common.js';
|
|
82
|
+
/**
|
|
83
|
+
* One job the bridge still has, as reported in the handshake (W6b).
|
|
84
|
+
*
|
|
85
|
+
* It carries the **slug and the state**, not only the id, because the cloud's
|
|
86
|
+
* reconciliation needs both and had neither. Reading `active_job_ids` as bare
|
|
87
|
+
* uuids, a restarted cloud could answer exactly one question — "is this job
|
|
88
|
+
* still alive?" — for jobs it already knew about. It could not name what the
|
|
89
|
+
* robot is doing, could not tell a job that is still `running` from one that
|
|
90
|
+
* finished while the cloud was down, and had nothing at all to say about a
|
|
91
|
+
* job it never recorded because it crashed between minting the id and writing
|
|
92
|
+
* the row.
|
|
93
|
+
*
|
|
94
|
+
* `state` is the bridge's own current answer, not a history. A bridge that
|
|
95
|
+
* has a terminal result still in hand reports it here and the cloud writes it
|
|
96
|
+
* down, instead of publishing `lost` over a job that in fact succeeded.
|
|
97
|
+
*/
|
|
98
|
+
export const activeJob = z.object({
|
|
99
|
+
job_id: z.uuid(),
|
|
100
|
+
slug,
|
|
101
|
+
state: jobState,
|
|
102
|
+
});
|
|
103
|
+
/** First frame a bridge sends after the socket opens. */
|
|
104
|
+
export const bridgeHello = z.object({
|
|
105
|
+
type: z.literal('hello'),
|
|
106
|
+
protocol_version: z.number().int().positive(),
|
|
107
|
+
token: z.string().min(1),
|
|
108
|
+
bridge_version: z.string().min(1),
|
|
109
|
+
/**
|
|
110
|
+
* Every job this bridge still knows about, right now (spec §6.1, W4).
|
|
111
|
+
*
|
|
112
|
+
* A reconnect and a restart look **identical** on the wire otherwise: same
|
|
113
|
+
* token, same version, same frame. But they must end differently — after a
|
|
114
|
+
* dropped connection the running jobs are still running, after a restart
|
|
115
|
+
* their results are gone forever. Asking the bridge to enumerate what it
|
|
116
|
+
* still has settles it without either side guessing: the cloud marks every
|
|
117
|
+
* job it believes is running on this robot and that is *not* named here as
|
|
118
|
+
* `lost`.
|
|
119
|
+
*
|
|
120
|
+
* This deliberately needs no persistence at the bridge. A live process
|
|
121
|
+
* lists its live jobs; a process that just started lists none, because it
|
|
122
|
+
* has none — which is exactly the truth the cloud needs. A breadcrumb file
|
|
123
|
+
* would only add a window in which the crash beat the write.
|
|
124
|
+
*
|
|
125
|
+
* Defaulted so pre-W4 bridges still parse; they had no jobs, so the empty
|
|
126
|
+
* list is also the correct answer for them.
|
|
127
|
+
*
|
|
128
|
+
* **Renamed from `active_job_ids` in W6b**, when the entries stopped being
|
|
129
|
+
* ids. A field called `_ids` holding objects is the shape this project has
|
|
130
|
+
* repeatedly been caught by — a name that describes what the field used to
|
|
131
|
+
* carry, kept because renaming looked like churn. Nothing is deployed yet
|
|
132
|
+
* (W8 is the first deployment), so the old name is gone rather than
|
|
133
|
+
* accepted alongside the new one: two accepted spellings would have to be
|
|
134
|
+
* supported and reconciled forever, and nobody is asking for that.
|
|
135
|
+
*/
|
|
136
|
+
active_jobs: z.array(activeJob).max(500).default([]),
|
|
137
|
+
});
|
|
138
|
+
/** Cloud accepts the bridge: the robot is online from here on. */
|
|
139
|
+
export const cloudHelloOk = z.object({
|
|
140
|
+
type: z.literal('hello_ok'),
|
|
141
|
+
robot_id: z.uuid(),
|
|
142
|
+
});
|
|
143
|
+
/** Cloud refuses the bridge (bad token, incompatible protocol, ...). */
|
|
144
|
+
export const cloudHelloError = z.object({
|
|
145
|
+
type: z.literal('hello_error'),
|
|
146
|
+
code: z.string().min(1),
|
|
147
|
+
message: z.string().min(1),
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
150
|
+
* One datapoint sample. `timestamp_ms` is the capture time at the bridge —
|
|
151
|
+
* never the receive time — so clients compute age themselves (spec §6.3).
|
|
152
|
+
*/
|
|
153
|
+
export const datapointFrame = z.object({
|
|
154
|
+
type: z.literal('datapoint'),
|
|
155
|
+
slug,
|
|
156
|
+
value: z.unknown(),
|
|
157
|
+
timestamp_ms: z.number().int().nonnegative(),
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* Latency probe, cloud → bridge. The cloud sends its own clock in `ts_ms`;
|
|
161
|
+
* the bridge echoes it back untouched and the cloud derives the round-trip
|
|
162
|
+
* latency shown as `bridge_state.latency_ms`.
|
|
163
|
+
*/
|
|
164
|
+
export const cloudPing = z.object({
|
|
165
|
+
type: z.literal('ping'),
|
|
166
|
+
ts_ms: z.number().int().nonnegative(),
|
|
167
|
+
});
|
|
168
|
+
/** Immediate bridge answer to a `CloudPing`, `ts_ms` echoed unchanged. */
|
|
169
|
+
export const bridgePong = z.object({
|
|
170
|
+
type: z.literal('pong'),
|
|
171
|
+
ts_ms: z.number().int().nonnegative(),
|
|
172
|
+
});
|
|
173
|
+
/**
|
|
174
|
+
* The published configuration, cloud → bridge (spec §4.1: the bridge applies
|
|
175
|
+
* the published version). Sent right after `hello_ok` and again on every
|
|
176
|
+
* publish, so a bridge never has to ask.
|
|
177
|
+
*
|
|
178
|
+
* `version: 0` with an empty document means *nothing published yet* — a fresh
|
|
179
|
+
* robot, not an error.
|
|
180
|
+
*/
|
|
181
|
+
/**
|
|
182
|
+
* Cloud → bridge: the configuration to apply.
|
|
183
|
+
*
|
|
184
|
+
* **`doc` is the whole of it.** Camera credentials travel inline in
|
|
185
|
+
* `doc.cameras[].source`, per `config.ts`'s `cameraCredentials` — there is no
|
|
186
|
+
* side channel any more. That was a deliberate, recorded trade-off: a
|
|
187
|
+
* password here is in every published version, and those are immutable. The
|
|
188
|
+
* bound on that decision is elsewhere and load-bearing — the publish audit
|
|
189
|
+
* event and the org event stream must not carry the document body.
|
|
190
|
+
*
|
|
191
|
+
* **The bridge does not persist configuration.** It holds this frame in
|
|
192
|
+
* memory and is sent it again on every reconnect, and that is the only thing
|
|
193
|
+
* keeping camera passwords off the robot's disk. The retired side channel
|
|
194
|
+
* carried this warning with an escape hatch attached — cache the config, just
|
|
195
|
+
* exclude the `credentials` field. There is no such field now: the secrets are
|
|
196
|
+
* inside `doc`, so caching the configuration caches the passwords, with
|
|
197
|
+
* nothing left to leave out. The warning survives its own mechanism, narrower
|
|
198
|
+
* and harder to satisfy than when it was written.
|
|
199
|
+
*/
|
|
200
|
+
export const cloudConfig = z.object({
|
|
201
|
+
type: z.literal('config'),
|
|
202
|
+
version: z.number().int().nonnegative(),
|
|
203
|
+
doc: robotConfigDoc,
|
|
204
|
+
});
|
|
205
|
+
/**
|
|
206
|
+
* What the bridge made of it. A single unusable entry must never stop the
|
|
207
|
+
* others: the bridge applies what it can, reports the rest per slug, and
|
|
208
|
+
* sets `ok: false`. The console shows this as "published v2 · applied v1".
|
|
209
|
+
*/
|
|
210
|
+
export const bridgeConfigApplied = z.object({
|
|
211
|
+
type: z.literal('config_applied'),
|
|
212
|
+
version: z.number().int().nonnegative(),
|
|
213
|
+
ok: z.boolean(),
|
|
214
|
+
errors: z.array(applyError),
|
|
215
|
+
});
|
|
216
|
+
/**
|
|
217
|
+
* Commands, cloud → bridge (spec §6.1, §11.3). The **cloud** mints the
|
|
218
|
+
* `job_id` before the bridge is asked to do anything, so a job exists —
|
|
219
|
+
* and can be reported `lost` — even if the answer never comes back.
|
|
220
|
+
*/
|
|
221
|
+
export const cloudInvoke = z.object({
|
|
222
|
+
type: z.literal('invoke'),
|
|
223
|
+
job_id: z.uuid(),
|
|
224
|
+
slug,
|
|
225
|
+
/**
|
|
226
|
+
* Already validated against §4.4 rules; the bridge validates structurally.
|
|
227
|
+
*
|
|
228
|
+
* **Flat, keyed by parameter name** — `{"target_x": 1}`. The key is a key of
|
|
229
|
+
* the entry's `parameters` mapping, not a path into the message. Those were
|
|
230
|
+
* the same thing until FL-002 and are now deliberately decoupled: a
|
|
231
|
+
* parameter keeps its name when the field it fills moves in the message
|
|
232
|
+
* tree, which is the same reason a slug is not a topic name.
|
|
233
|
+
*
|
|
234
|
+
* Three things follow, and the last one got stronger rather than weaker:
|
|
235
|
+
* the key a caller sends is the key a rule names, so a `parameter_invalid`
|
|
236
|
+
* reports something the caller can find; the console binds one input per
|
|
237
|
+
* parameter; and a position the template does not mark with `${…}` cannot
|
|
238
|
+
* be set by any caller at all. That last one used to be a rule about what
|
|
239
|
+
* no `parameterSpec` declared. It is now structural — the value has nowhere
|
|
240
|
+
* to go.
|
|
241
|
+
*
|
|
242
|
+
* The bridge substitutes these values into the entry's `message` template
|
|
243
|
+
* at its placeholder positions. It no longer unflattens a dotted path;
|
|
244
|
+
* there is no dotted path to unflatten.
|
|
245
|
+
*/
|
|
246
|
+
params: z.record(z.string(), z.unknown()),
|
|
247
|
+
/**
|
|
248
|
+
* How long this one call is worth waiting for (W6b), already resolved by
|
|
249
|
+
* the cloud — the caller's `invokeRequest.patience_ms`, or
|
|
250
|
+
* `DEFAULT_PATIENCE_MS` when they named none.
|
|
251
|
+
*
|
|
252
|
+
* **Required here, optional at REST**, deliberately. At the REST edge an
|
|
253
|
+
* absent value is a caller who did not care and gets the default. By the
|
|
254
|
+
* time the frame is on this socket somebody has decided, and the bridge
|
|
255
|
+
* must never be in the position of picking a number the cloud is already
|
|
256
|
+
* counting against — which is what two independent 15 s constants meant in
|
|
257
|
+
* practice: a bridge that gave up at 15.0 s and a cloud that gave up at
|
|
258
|
+
* 15.0 s, agreeing only by accident, with no way to tell whose deadline a
|
|
259
|
+
* caller had actually hit.
|
|
260
|
+
*/
|
|
261
|
+
patience_ms: z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS),
|
|
262
|
+
});
|
|
263
|
+
/**
|
|
264
|
+
* Cancel — the bridge must issue a real ROS goal cancel (§11.3).
|
|
265
|
+
*
|
|
266
|
+
* `slug` stays, and stays required: it is how the bridge finds the tracker,
|
|
267
|
+
* and it is what a cancel with no id means.
|
|
268
|
+
*
|
|
269
|
+
* `job_id` is what W6b adds, and what makes a cancel say *which* job. Without
|
|
270
|
+
* it a cancel arriving a moment after one job ended and another began on the
|
|
271
|
+
* same slug stops the **new** one — the caller asked to stop something that
|
|
272
|
+
* had already finished and stopped a machine that had just started moving.
|
|
273
|
+
* That is not a race anybody had to lose: the caller knew the id, and the
|
|
274
|
+
* wire had nowhere to put it.
|
|
275
|
+
*
|
|
276
|
+
* `null` keeps today's meaning and must be read as exactly that: *cancel
|
|
277
|
+
* whatever is running on this slug*. It is a real request — an operator
|
|
278
|
+
* hitting stop wants the robot stopped, not a lecture about job identity —
|
|
279
|
+
* and it stays available for that. A bridge given an id that does not match
|
|
280
|
+
* what is running cancels **nothing** and says so; it must not fall back to
|
|
281
|
+
* the slug, because a caller who named an id has ruled that out.
|
|
282
|
+
*/
|
|
283
|
+
export const cloudCancel = z.object({
|
|
284
|
+
type: z.literal('cancel'),
|
|
285
|
+
slug,
|
|
286
|
+
job_id: z.uuid().nullable(),
|
|
287
|
+
});
|
|
288
|
+
export const cloudPublish = z.object({
|
|
289
|
+
type: z.literal('publish'),
|
|
290
|
+
slug,
|
|
291
|
+
/**
|
|
292
|
+
* Flat and keyed by parameter name, exactly like `cloudInvoke.params` — a
|
|
293
|
+
* publisher declares parameters and takes the same validation, so it takes
|
|
294
|
+
* the same shape.
|
|
295
|
+
*
|
|
296
|
+
* This is *not* the shape of `publisherConfig.failsafe.message`, which is a
|
|
297
|
+
* complete ROS message template. The failsafe is authored once against the
|
|
298
|
+
* type, sent by the bridge with no caller present, and refused outright if
|
|
299
|
+
* it contains a placeholder — there would be nobody to fill it.
|
|
300
|
+
*/
|
|
301
|
+
message: z.record(z.string(), z.unknown()),
|
|
302
|
+
});
|
|
303
|
+
/**
|
|
304
|
+
* Progress on a job, bridge → cloud. `timestamp_ms` is capture time, so a
|
|
305
|
+
* burst delivered late after a reconnect is visibly late (§6.3).
|
|
306
|
+
*/
|
|
307
|
+
export const bridgeJobUpdate = z.object({
|
|
308
|
+
type: z.literal('job_update'),
|
|
309
|
+
job_id: z.uuid(),
|
|
310
|
+
slug,
|
|
311
|
+
state: jobState,
|
|
312
|
+
feedback: z.unknown().nullable(),
|
|
313
|
+
progress: z.number().min(0).max(1).nullable(),
|
|
314
|
+
result: z.unknown().nullable(),
|
|
315
|
+
/** Same shape as `job.error`, `details` included — see `jobs.ts`. */
|
|
316
|
+
error: z
|
|
317
|
+
.object({ code: z.string().min(1), message: z.string().min(1), details: z.unknown().optional() })
|
|
318
|
+
.nullable(),
|
|
319
|
+
timestamp_ms: z.number().int().nonnegative(),
|
|
320
|
+
});
|
|
321
|
+
/**
|
|
322
|
+
* Jobs the bridge can no longer account for **while connected** (§6.1) — a
|
|
323
|
+
* tracker dropped, an action server that vanished mid-goal, anything where
|
|
324
|
+
* the honest answer is "I lost this" rather than a state.
|
|
325
|
+
*
|
|
326
|
+
* The restart case is not this frame's job: a restarted bridge has nothing
|
|
327
|
+
* left to enumerate, so it is `hello.active_job_ids` that closes that gap.
|
|
328
|
+
* Both paths end in the same place — the cloud publishes `lost` rather than
|
|
329
|
+
* leaving a job reading "running" because nobody contradicted it.
|
|
330
|
+
*/
|
|
331
|
+
export const bridgeJobLost = z.object({
|
|
332
|
+
type: z.literal('job_lost'),
|
|
333
|
+
job_ids: z.array(z.uuid()),
|
|
334
|
+
});
|
|
335
|
+
/** Cloud asks for a fresh ROS graph; `request_id` correlates the answer. */
|
|
336
|
+
export const cloudIntrospectRequest = z.object({
|
|
337
|
+
type: z.literal('introspect_request'),
|
|
338
|
+
request_id: z.string().min(1).max(64),
|
|
339
|
+
});
|
|
340
|
+
/** The graph snapshot, bridge → cloud. */
|
|
341
|
+
export const bridgeIntrospect = z.object({
|
|
342
|
+
type: z.literal('introspect'),
|
|
343
|
+
request_id: z.string().min(1).max(64),
|
|
344
|
+
graph: rosGraph,
|
|
345
|
+
});
|
|
346
|
+
/**
|
|
347
|
+
* Field trees are fetched on demand, not shipped with the graph: a robot with
|
|
348
|
+
* hundreds of topics would otherwise push hundreds of kilobytes on every
|
|
349
|
+
* refresh, for types nobody opened.
|
|
350
|
+
*/
|
|
351
|
+
export const cloudTypeRequest = z.object({
|
|
352
|
+
type: z.literal('type_request'),
|
|
353
|
+
request_id: z.string().min(1).max(64),
|
|
354
|
+
type_names: z.array(rosTypeName).min(1).max(50),
|
|
355
|
+
});
|
|
356
|
+
/**
|
|
357
|
+
* The resolved definitions. Names the bridge cannot resolve in its sourced
|
|
358
|
+
* workspace are listed in `unresolved` — an unknown type is an answer, not a
|
|
359
|
+
* failed frame.
|
|
360
|
+
*/
|
|
361
|
+
export const bridgeTypeDefinitions = z.object({
|
|
362
|
+
type: z.literal('type_definitions'),
|
|
363
|
+
request_id: z.string().min(1).max(64),
|
|
364
|
+
definitions: z.array(typeDefinition),
|
|
365
|
+
unresolved: z.array(z.string()),
|
|
366
|
+
});
|
|
367
|
+
/**
|
|
368
|
+
* The built-in `bridge_state` datapoint every robot has (spec §4.3):
|
|
369
|
+
* connection status plus latency, the basis for offline-aware client UIs.
|
|
370
|
+
*/
|
|
371
|
+
export const bridgeState = z.object({
|
|
372
|
+
online: z.boolean(),
|
|
373
|
+
latency_ms: z.number().nonnegative().nullable(),
|
|
374
|
+
});
|
|
375
|
+
/** One tier's counters, `tiers` below carries six of these under string keys. */
|
|
376
|
+
const bridgePressureTier = z.object({
|
|
377
|
+
sent: z.number().int().nonnegative(),
|
|
378
|
+
bytes: z.number().int().nonnegative(),
|
|
379
|
+
drops: z.number().int().nonnegative(),
|
|
380
|
+
high_water: z.number().int().nonnegative(),
|
|
381
|
+
});
|
|
382
|
+
/**
|
|
383
|
+
* The built-in `bridge_pressure` datapoint (spec §4.3, the pressure-telemetry
|
|
384
|
+
* design's "The decision that shapes everything"): the bridge's own
|
|
385
|
+
* bandwidth-shaping state, sent on the same reserved-slug path as
|
|
386
|
+
* `bridge_state` so history, realtime, REST and MCP exposure fall out of the
|
|
387
|
+
* ordinary datapoint machinery for free.
|
|
388
|
+
*/
|
|
389
|
+
export const bridgePressure = z.object({
|
|
390
|
+
link: z.object({
|
|
391
|
+
/** bytes/s the socket demonstrably drains, from sends >= 64 KiB
|
|
392
|
+
* only; null until the first large send of the session. */
|
|
393
|
+
rate_bps: z.number().nonnegative().nullable(),
|
|
394
|
+
/**
|
|
395
|
+
* the byte target snapshots are currently encoded to fit.
|
|
396
|
+
*
|
|
397
|
+
* `.nonnegative()`, not `.positive()`: the target is derived from
|
|
398
|
+
* `rate_bps`, and a link measured below 0.5 B/s floors to 0 here. A
|
|
399
|
+
* schema that rejects 0 does not prevent that link — it only makes the
|
|
400
|
+
* frame reporting it unparseable, and a console that cannot parse a
|
|
401
|
+
* pressure frame shows "no feed", i.e. reports a struggling robot as an
|
|
402
|
+
* *old* one. Zero is a legitimate reading and says something true.
|
|
403
|
+
*/
|
|
404
|
+
snapshot_max_bytes: z.number().int().nonnegative(),
|
|
405
|
+
}),
|
|
406
|
+
/**
|
|
407
|
+
* String keys "0".."5" because JSON has no integer keys. Counters are
|
|
408
|
+
* cumulative per session and reset on reconnect; clients window by
|
|
409
|
+
* differencing two samples.
|
|
410
|
+
*
|
|
411
|
+
* **What this schema does not decide:** it does not guarantee all six
|
|
412
|
+
* keys are present (`z.record` over the six literals is exhaustive in
|
|
413
|
+
* zod 4 — tested here, it required every key and rejected none, the
|
|
414
|
+
* opposite of what a partial sample needs — so this is a
|
|
415
|
+
* `.strictObject().partial()` over the same six literal keys instead, a
|
|
416
|
+
* deliberate deviation from the originally sketched `z.record` shape with
|
|
417
|
+
* the same runtime behaviour). A missing tier key reads as zeros; the
|
|
418
|
+
* schema names what it cannot decide rather than implying a completeness
|
|
419
|
+
* it cannot check.
|
|
420
|
+
*/
|
|
421
|
+
tiers: z
|
|
422
|
+
.strictObject({
|
|
423
|
+
'0': bridgePressureTier,
|
|
424
|
+
'1': bridgePressureTier,
|
|
425
|
+
'2': bridgePressureTier,
|
|
426
|
+
'3': bridgePressureTier,
|
|
427
|
+
'4': bridgePressureTier,
|
|
428
|
+
'5': bridgePressureTier,
|
|
429
|
+
})
|
|
430
|
+
.partial(),
|
|
431
|
+
video: z.object({
|
|
432
|
+
active_streams: z.number().int().nonnegative(),
|
|
433
|
+
bitrate_sum_kbps: z.number().int().nonnegative(),
|
|
434
|
+
/**
|
|
435
|
+
* The uplink budget the bridge was configured with
|
|
436
|
+
* (`FLEETLESS_UPLINK_KBPS`), or `null` when none was set.
|
|
437
|
+
*
|
|
438
|
+
* `.nonnegative()`, not `.positive()`: `FLEETLESS_UPLINK_KBPS=0` is a
|
|
439
|
+
* documented setting meaning "no video budget at all", and the bridge
|
|
440
|
+
* emits that 0 verbatim. `.positive()` made every frame from such a
|
|
441
|
+
* robot fail the console's `safeParse`, which renders an unparseable
|
|
442
|
+
* frame as "no pressure feed" — so the one robot that had *deliberately*
|
|
443
|
+
* turned video off was the one diagnosed as running a bridge too old to
|
|
444
|
+
* report pressure. A value the producer legitimately sends must parse;
|
|
445
|
+
* `null` is the only "not set" this field has.
|
|
446
|
+
*/
|
|
447
|
+
uplink_kbps: z.number().int().nonnegative().nullable(),
|
|
448
|
+
override_kbps: z.number().int().nonnegative().nullable(),
|
|
449
|
+
video_budget_kbps: z.number().int().nonnegative().nullable(),
|
|
450
|
+
reserve_kbps: z.number().int().nonnegative(),
|
|
451
|
+
}),
|
|
452
|
+
});
|
|
453
|
+
export const PRESSURE_SLUG = 'bridge_pressure';
|
|
454
|
+
/* ------------------------------------------------------------------ W5 --
|
|
455
|
+
* Cameras (spec §10).
|
|
456
|
+
*/
|
|
457
|
+
/**
|
|
458
|
+
* The header of a **binary** snapshot frame, bridge → cloud.
|
|
459
|
+
*
|
|
460
|
+
* A snapshot frame is laid out as:
|
|
461
|
+
*
|
|
462
|
+
* [4-byte big-endian header length][UTF-8 JSON header][image bytes]
|
|
463
|
+
*
|
|
464
|
+
* Binary rather than base64 in a text frame, because base64 costs a third of
|
|
465
|
+
* the robot's upstream for nothing. Self-contained rather than a JSON frame
|
|
466
|
+
* followed by a binary one, because that pairing would depend on frame
|
|
467
|
+
* ordering — and W4 established, at some cost, that ordering across a socket
|
|
468
|
+
* is not something to lean on.
|
|
469
|
+
*
|
|
470
|
+
* `timestamp_ms` is the bridge's **capture** time (§6.3), which is what lets
|
|
471
|
+
* every consumer state a snapshot's true age. A picture that lies about when
|
|
472
|
+
* it was taken is this wave's version of a job that reads "running" when
|
|
473
|
+
* nobody knows.
|
|
474
|
+
*/
|
|
475
|
+
/**
|
|
476
|
+
* The largest a snapshot frame — header and image bytes together — may be on
|
|
477
|
+
* the `/bridge` socket.
|
|
478
|
+
*
|
|
479
|
+
* This is a **byte** bound and not a pixel one, deliberately. A camera's
|
|
480
|
+
* `width`/`height` govern the *live* stream, which travels through LiveKit
|
|
481
|
+
* and never touches this socket, so capping resolution to protect the socket
|
|
482
|
+
* would cost live quality to solve a snapshot problem.
|
|
483
|
+
*
|
|
484
|
+
* The bound exists because exceeding it is not a dropped frame: `ws` enforces
|
|
485
|
+
* its payload limit before the frame is ever delivered and closes the
|
|
486
|
+
* connection with 1009 — taking datapoints, jobs, commands and configuration
|
|
487
|
+
* down with it. The bridge would then reconnect, receive the same
|
|
488
|
+
* configuration, capture the same frame and be closed again: a robot that
|
|
489
|
+
* will not stay online, from a configuration the platform accepted. Measured
|
|
490
|
+
* during the W5 review, a 4K JPEG of real camera content lands around
|
|
491
|
+
* 2.2 MiB and 1080p on a noisy scene within 40% of this number, so the margin
|
|
492
|
+
* is thinner than it looks.
|
|
493
|
+
*
|
|
494
|
+
* **The bridge must degrade rather than exceed it** — lower JPEG quality,
|
|
495
|
+
* then downscale, and if it still does not fit, skip the frame and say so.
|
|
496
|
+
* A missing snapshot is a gap, and this wave already established that a gap
|
|
497
|
+
* is an honest answer; a closed socket is not.
|
|
498
|
+
*/
|
|
499
|
+
export const SNAPSHOT_MAX_BYTES = 1_572_864; // 1.5 MiB, against a 2 MiB socket ceiling
|
|
500
|
+
export const snapshotHeader = z.object({
|
|
501
|
+
type: z.literal('snapshot'),
|
|
502
|
+
slug,
|
|
503
|
+
/** `image/jpeg` in practice; stated so nothing has to sniff the bytes. */
|
|
504
|
+
mime: z.string().min(1).max(64),
|
|
505
|
+
width: z.number().int().positive(),
|
|
506
|
+
height: z.number().int().positive(),
|
|
507
|
+
timestamp_ms: z.number().int().nonnegative(),
|
|
508
|
+
});
|
|
509
|
+
/**
|
|
510
|
+
* Cloud → bridge: start publishing this camera live.
|
|
511
|
+
*
|
|
512
|
+
* The **cloud** mints the room and the publisher token, for the same reason
|
|
513
|
+
* it mints a `job_id` before asking anything (§6.1): the side that owns the
|
|
514
|
+
* refcount must own the identity of the stream, or a robot could end up
|
|
515
|
+
* publishing into a room nobody is watching.
|
|
516
|
+
*/
|
|
517
|
+
export const cloudCameraStart = z.object({
|
|
518
|
+
type: z.literal('camera_start'),
|
|
519
|
+
slug,
|
|
520
|
+
url: z.string().min(1),
|
|
521
|
+
room: z.string().min(1),
|
|
522
|
+
token: z.string().min(1),
|
|
523
|
+
/**
|
|
524
|
+
* Names **this attempt** (W6b), and is echoed in the `camera_state` that
|
|
525
|
+
* answers it.
|
|
526
|
+
*
|
|
527
|
+
* W6a gave `camera_state` a `cause` and said in the same comment that a
|
|
528
|
+
* cause is not a correlation. This is the other half. Start a camera, have
|
|
529
|
+
* it fail slowly, start it again: the first attempt's failure arrives while
|
|
530
|
+
* the second is in flight, matches on slug, and resolves the attempt it
|
|
531
|
+
* knows nothing about. The viewer is then told the running stream failed,
|
|
532
|
+
* for a reason belonging to an attempt that is already over.
|
|
533
|
+
*/
|
|
534
|
+
request_id: z.string().min(1).max(64),
|
|
535
|
+
});
|
|
536
|
+
/** Cloud → bridge: the last viewer left; stop publishing (§10 refcount). */
|
|
537
|
+
/**
|
|
538
|
+
* Assets (spec §4.6, W7): the bridge **reports availability and transfers
|
|
539
|
+
* nothing** until asked.
|
|
540
|
+
*
|
|
541
|
+
* **The bytes never travel on this socket.** `server.ts` caps a frame at
|
|
542
|
+
* 2 MiB, a single mesh exceeds that routinely, and raising the cap is already
|
|
543
|
+
* tied to W8's rate limiting in the deferral register because it amplifies an
|
|
544
|
+
* unauthenticated path. So the socket carries the *conversation* — what exists,
|
|
545
|
+
* transfer this, here is how far I got — and the bytes go over HTTP with the
|
|
546
|
+
* robot's own credential.
|
|
547
|
+
*
|
|
548
|
+
* That split is the whole design: a 40 MB mesh cannot stall the frames that
|
|
549
|
+
* keep a robot answerable, and a failed upload cannot take the control channel
|
|
550
|
+
* down with it.
|
|
551
|
+
*/
|
|
552
|
+
export const bridgeAssetsAvailable = z.object({
|
|
553
|
+
type: z.literal('assets_available'),
|
|
554
|
+
/** Whether `/robot_description` (or the configured source) yielded a URDF. */
|
|
555
|
+
urdf: z.boolean(),
|
|
556
|
+
/**
|
|
557
|
+
* Every `package://` URI the URDF references, verbatim and unresolved —
|
|
558
|
+
* including the ones this bridge cannot find in its workspace. Reporting
|
|
559
|
+
* only the resolvable ones would make an incomplete workspace look like a
|
|
560
|
+
* complete robot, and the cloud would have nothing to show as missing.
|
|
561
|
+
*/
|
|
562
|
+
meshes: z.array(z.string().min(1)),
|
|
563
|
+
});
|
|
564
|
+
/**
|
|
565
|
+
* The explicit request §4.6 requires — nothing moves without it.
|
|
566
|
+
*
|
|
567
|
+
* The upload credential is minted per sync and travels here rather than being
|
|
568
|
+
* derived from the robot token: it is scoped to one robot's assets and one
|
|
569
|
+
* sync, so a bridge cannot be talked into uploading somewhere else, and an
|
|
570
|
+
* expired one fails a sync instead of failing a robot.
|
|
571
|
+
*/
|
|
572
|
+
export const cloudAssetRequest = z.object({
|
|
573
|
+
type: z.literal('asset_request'),
|
|
574
|
+
sync_id: z.uuid(),
|
|
575
|
+
upload_url: z.url(),
|
|
576
|
+
token: z.string().min(1),
|
|
577
|
+
/** Which URIs to send. Empty means the URDF only. */
|
|
578
|
+
meshes: z.array(z.string().min(1)),
|
|
579
|
+
});
|
|
580
|
+
/**
|
|
581
|
+
* How far a sync got, and — required, not optional — what it could not do.
|
|
582
|
+
*
|
|
583
|
+
* `failed` carries the URIs that did not resolve. A sync that quietly drops
|
|
584
|
+
* three meshes and reports success moves the failure into somebody else's
|
|
585
|
+
* renderer, where it appears as a robot with missing limbs and no cause.
|
|
586
|
+
*/
|
|
587
|
+
export const bridgeAssetProgress = z.object({
|
|
588
|
+
type: z.literal('asset_progress'),
|
|
589
|
+
sync_id: z.uuid(),
|
|
590
|
+
done: z.number().int().nonnegative(),
|
|
591
|
+
total: z.number().int().nonnegative(),
|
|
592
|
+
/**
|
|
593
|
+
* **Each entry says why** — see `assetFailure` in `assets.ts` for the three
|
|
594
|
+
* kinds and why one word was not enough. The bound is `assets.ts`'s too: a
|
|
595
|
+
* `.dae` with 17,331 unresolvable internal references produced a frame 32
|
|
596
|
+
* bytes over `MAX_WS_PAYLOAD_BYTES`, and `ws` enforces that **before**
|
|
597
|
+
* delivery — so the outcome was the robot's own socket closed, mid-sync, by
|
|
598
|
+
* a file in its workspace (Kassandra-W7a). A producer at its own ceiling
|
|
599
|
+
* reports **one** `refused` entry naming the file, not one per reference.
|
|
600
|
+
*/
|
|
601
|
+
failed: z.array(assetFailure).max(1000),
|
|
602
|
+
/**
|
|
603
|
+
* Three values, because a boolean `finished` had nowhere to put a refusal.
|
|
604
|
+
*
|
|
605
|
+
* A second `asset_request` arriving while one is in flight has to be
|
|
606
|
+
* answered with something. The bridge's guard is a backstop — the cloud owns
|
|
607
|
+
* sync lifecycle and refuses a concurrent one first — but a backstop that
|
|
608
|
+
* answers with silence is a backstop nobody can debug, and the alternative
|
|
609
|
+
* on the table was to report every requested URI in `failed`. That would
|
|
610
|
+
* have made `failed` mean two different things at once — *could not be
|
|
611
|
+
* resolved* and *was never attempted* — which is the one-field-two-facts
|
|
612
|
+
* defect this project has now split five times (`set`/`readable`,
|
|
613
|
+
* `truncated`/`truncated_by`, `value`/`sample_count`, `publishing`/`cause`,
|
|
614
|
+
* and camera health's own).
|
|
615
|
+
*
|
|
616
|
+
* So: `running` while work is happening, `finished` when the bridge will
|
|
617
|
+
* send no more for this sync, `refused_busy` when it never started because
|
|
618
|
+
* another sync was in flight. `failed` keeps its single meaning.
|
|
619
|
+
*
|
|
620
|
+
* Raised by Rosie-W7, who found the gap by asking what a second request
|
|
621
|
+
* should do rather than picking the silent option.
|
|
622
|
+
*/
|
|
623
|
+
state: z.enum(['running', 'finished', 'refused_busy']),
|
|
624
|
+
});
|
|
625
|
+
export const cloudCameraStop = z.object({
|
|
626
|
+
type: z.literal('camera_stop'),
|
|
627
|
+
slug,
|
|
628
|
+
/** Names this stop, echoed by the `camera_state` that answers it — see `cloudCameraStart.request_id`. */
|
|
629
|
+
request_id: z.string().min(1).max(64),
|
|
630
|
+
});
|
|
631
|
+
/**
|
|
632
|
+
* What the bridge made of it. `publishing: false` with an `error` is how a
|
|
633
|
+
* camera that cannot start says so — the cloud must not leave a viewer
|
|
634
|
+
* watching a black rectangle while believing the stream is live.
|
|
635
|
+
*/
|
|
636
|
+
export const bridgeCameraState = z.object({
|
|
637
|
+
type: z.literal('camera_state'),
|
|
638
|
+
slug,
|
|
639
|
+
publishing: z.boolean(),
|
|
640
|
+
error: z.object({ code: z.string().min(1), message: z.string().min(1) }).nullable(),
|
|
641
|
+
/**
|
|
642
|
+
* Why this frame was sent (W6a).
|
|
643
|
+
*
|
|
644
|
+
* Without it, `{publishing: false, error: null}` is sent for **three
|
|
645
|
+
* different things** — an answer to `camera_stop`, a stream stopped by a
|
|
646
|
+
* configuration change, and a source that recovered — and the cloud can
|
|
647
|
+
* only tell them apart by remembering what it saw before. Deriving a cause
|
|
648
|
+
* from remembered state is precisely the inference this project keeps
|
|
649
|
+
* finding to be wrong, and W6a exists because four failures had been
|
|
650
|
+
* sharing one silence.
|
|
651
|
+
*
|
|
652
|
+
* `'command'` this frame answers a `camera_start` / `camera_stop`.
|
|
653
|
+
* `'source'` unsolicited: the source's own health changed, whether or
|
|
654
|
+
* not anybody is watching. This is the frame that makes a
|
|
655
|
+
* wrong password visible without a viewer.
|
|
656
|
+
* `'config_change'` a configuration change stopped this stream. Not a
|
|
657
|
+
* failure, and it must not be logged as one.
|
|
658
|
+
* `'live_lost'` publishing ended unexpectedly after it had started.
|
|
659
|
+
*
|
|
660
|
+
* Note it does **not** answer "which attempt is this?" — `camera_state`
|
|
661
|
+
* still has no request id, and that remains a named deferral in cluster C.
|
|
662
|
+
* `cause` says what kind of event this is; correlation is a separate fact
|
|
663
|
+
* and giving one field both jobs would be the same mistake again.
|
|
664
|
+
*
|
|
665
|
+
* Required, not optional: an absent cause would default to the reading
|
|
666
|
+
* somebody happens to assume, and every frame's sender knows its own
|
|
667
|
+
* reason. Old bridges fail validation on this frame — acceptable while
|
|
668
|
+
* nothing is deployed, and W8 is the first deployment.
|
|
669
|
+
*/
|
|
670
|
+
cause: z.enum(['command', 'source', 'config_change', 'live_lost']),
|
|
671
|
+
/**
|
|
672
|
+
* When the **robot** observed this state — bridge capture time, never
|
|
673
|
+
* receive time, the same discipline `timestamp_ms` follows for samples
|
|
674
|
+
* (spec §6.3).
|
|
675
|
+
*
|
|
676
|
+
* It exists because the cloud stamped `resourceHealthState.changed_at_ms`
|
|
677
|
+
* with its own `Date.now()`, and a **restatement** is by definition an old
|
|
678
|
+
* state re-sent into an empty map. So after a cloud restart every failure —
|
|
679
|
+
* including one from yesterday — was dated to the restart, in the one
|
|
680
|
+
* scenario `changed_at_ms`'s own doc comment was written for: *"a page that
|
|
681
|
+
* loads late must be able to tell a failure from a minute ago from one from
|
|
682
|
+
* yesterday"*.
|
|
683
|
+
*
|
|
684
|
+
* On a restatement this carries **when the state was first observed**, not
|
|
685
|
+
* when the frame was sent. A bridge that re-states a failure it has held for
|
|
686
|
+
* an hour says so.
|
|
687
|
+
*/
|
|
688
|
+
observed_at_ms: z.number().int().nonnegative(),
|
|
689
|
+
/**
|
|
690
|
+
* Which request this frame answers (W6b), or `null` when it answers none.
|
|
691
|
+
*
|
|
692
|
+
* `null` is not a gap and must not be treated as one: a `cause: 'source'`
|
|
693
|
+
* frame — the unsolicited health report that makes a wrong password visible
|
|
694
|
+
* with nobody watching — answers no request by definition, and so does a
|
|
695
|
+
* `config_change` stop. Those are the majority of frames on a healthy
|
|
696
|
+
* system.
|
|
697
|
+
*
|
|
698
|
+
* A frame with `cause: 'command'` carries the `request_id` of the
|
|
699
|
+
* `camera_start` or `camera_stop` it answers. **The cloud resolves a
|
|
700
|
+
* pending attempt only on a matching id**, and drops a `command` frame
|
|
701
|
+
* whose id it no longer recognises rather than applying it to whatever is
|
|
702
|
+
* pending — a late answer to a cancelled attempt is stale, not current.
|
|
703
|
+
*
|
|
704
|
+
* **The pairing rule is not in this schema, deliberately.** "Non-null iff
|
|
705
|
+
* `cause === 'command'`" is a cross-field constraint; a zod `.refine()`
|
|
706
|
+
* would express it at runtime and then **disappear** from the generated
|
|
707
|
+
* JSON Schema, which is what the bridge vendors. The cloud would reject
|
|
708
|
+
* frames the bridge had validated as correct — the same artifact/runtime
|
|
709
|
+
* divergence that `.default()` publishing as `required` has produced four
|
|
710
|
+
* times in this project, only pointing the other way. The rule is enforced
|
|
711
|
+
* where the correlation is used, in the cloud's bridge frame handler, and
|
|
712
|
+
* stated here so nobody has to derive it from that code.
|
|
713
|
+
*/
|
|
714
|
+
request_id: z.string().min(1).max(64).nullable(),
|
|
715
|
+
});
|