@fleetless/contracts 3.0.0 → 4.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 CHANGED
@@ -3,7 +3,31 @@
3
3
  All notable changes to `@fleetless/contracts` are recorded here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
5
5
  project uses [semantic versioning](https://semver.org/spec/v2.0.0.html) over
6
- the wire shapes.
6
+ the wire shapes. A pull request that changes what a consumer sees adds its
7
+ entry under `## [Unreleased]`; the release renames that heading to the
8
+ version.
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [4.0.0] — 2026-09-29
13
+
14
+ ### Added
15
+
16
+ - **Protocol 4: a job heartbeat, and a vanished action server ends its job.**
17
+ Protocol bumped: bridges from `5.0.0`, and protocol 3 sunsets
18
+ 2026-12-28 (protocol 2 sunset 2026-12-21). The bridge now sends a
19
+ `job_update` heartbeat every `JOB_HEARTBEAT_INTERVAL_MS` for every running
20
+ job, and ends a job whose action server vanished `lost` with
21
+ `action_server_lost` instead of leaving the cloud to guess; `job_lost`
22
+ gains an optional `error` that can say the same. `JOB_HEARTBEAT_TIMEOUT_MS` bounds a protocol-4 job's
23
+ silence once it has been heard from at all; `patience_ms` now bounds only
24
+ the acceptance gap on such a job (unchanged for protocol 3, which sends no
25
+ heartbeat). `JOB_OFFLINE_GRACE_MS` (five minutes) replaces the informal
26
+ one-minute disconnect grace a job got before. `errors.ts` documents
27
+ `action_server_lost` and every job error code already in use that had
28
+ never been written down: `action_failed`, `goal_rejected`,
29
+ `goal_send_failed`, `result_failed`, `goal_uncontrollable`,
30
+ `bridge_disconnected`, `config_changed`.
7
31
 
8
32
  ## [3.0.0] — 2026-09-22
9
33
 
package/CONTRIBUTING.md CHANGED
@@ -73,8 +73,9 @@ an issue first — so we can say what else has to move with it.
73
73
 
74
74
  **CI runs on GitHub Actions**, in this repository
75
75
  (`.github/workflows/verify.yml`) — the suite, on every push and every pull
76
- request. `release.yml` publishes on a release tag and calls that same file
77
- first, so a release is never checked by a different pipeline than a push.
76
+ request. `release.yml` (the **Release** button) calls that same file on the
77
+ commit it publishes, so a release is never checked by a different pipeline
78
+ than a push.
78
79
 
79
80
  **Your pull request is verified, a fork's included** — the same file, the
80
81
  same suite. The first run by a first-time contributor waits for a maintainer
@@ -82,10 +83,11 @@ to press approve on it; that is a button on your run, not a setting anybody
82
83
  has to change, so checks sitting idle for a while are the queue and not a
83
84
  failure. The run reads code and reaches nothing else: it is granted
84
85
  `contents: read`, no secret is exposed to it, and publishing lives in a
85
- workflow only a tag can trigger.
86
+ workflow only a maintainer's own **Run workflow** press can trigger.
86
87
 
87
- Run `pnpm typecheck && pnpm build && pnpm test && pnpm artifacts && pnpm run
88
- test:pack` yourself first and you've seen everything `verify` will tell you.
88
+ Run `pnpm typecheck && pnpm build && pnpm test && node --test
89
+ '.github/release/*.test.mjs' && pnpm artifacts && pnpm run test:pack`
90
+ yourself first and you've seen everything `verify` will tell you.
89
91
  Mind `pnpm artifacts`: `artifacts/` is generated *and* committed, and CI
90
92
  fails if regenerating it changes a file — so commit whatever it writes
91
93
  together with the schema you changed. That is the single most common reason
@@ -123,22 +125,31 @@ is refused by a `prepublishOnly` script — the rule has a mechanism rather
123
125
  than only a sentence. (`publish` is unaffected: it publishes the tarball
124
126
  `verify` packed, and npm runs no prepare lifecycle for a tarball argument.)
125
127
 
126
- 1. Update `CHANGELOG.md` and set the new version in `package.json`. **CI
127
- checks both** (`scripts/verify-version-tag.mjs`): `package.json` must
128
- equal the tag without its `v`, and `CHANGELOG.md` needs a dated heading
129
- reading exactly `## [X.Y.Z] — YYYY-MM-DD`. `CHANGELOG.md` ships inside
130
- the tarball — skip an entry and you've documented the wrong version to
131
- every consumer, and npm won't take a version back.
132
-
133
- The same script refuses a release tag that would move npm's `latest`
134
- backwards — a backported `v1.0.1` published while `latest` is `2.0.0`
135
- would make `npm i @fleetless/contracts`, the command the README gives
136
- outsiders, install a package a major version behind.
137
- 2. Commit, push, and let the `verify` job go green on the branch (the
138
- Actions tab).
139
- 3. Tag `vX.Y.Z` (or `vX.Y.Z-beta.N` for a pre-release, which publishes to
140
- the `next` dist-tag) and push the tag. The tag run's `verify` job runs
141
- again and then `publish`.
128
+ **Release is a button**, not a tag you push. Press **Run workflow** on
129
+ `release` (the Actions tab), on `main`. The version comes from the
130
+ Conventional Commits since the last tag; a release PR turns
131
+ `CHANGELOG.md`'s `## [Unreleased]` into that version, dated, and sets it in
132
+ `package.json` — the same two things `scripts/verify-version-tag.mjs` always
133
+ checked, now written by the release PR instead of by hand. That PR merges
134
+ itself once the required `verify` check passes; the merge commit is tagged,
135
+ `verify.yml` runs again on it and packs the tarball, and `publish` ships
136
+ exactly that tarball.
137
+
138
+ A person still writes the `## [Unreleased]` entries — in the feature's own
139
+ pull request, as the change goes in — because the release only renames that
140
+ heading to a version; it never writes prose. An empty `## [Unreleased]`
141
+ refuses the release outright, before any branch or commit exists.
142
+
143
+ A pull request that moves the protocol window writes one of those entries
144
+ too, and `test/changelog.test.ts` is red until it does: some section has to
145
+ name the newest `bridge_from` together with the date the version before it
146
+ sunsets. It need not be the newest section — a release that leaves the
147
+ protocol alone has nothing true to restate about it.
148
+
149
+ For a pre-release — a branch elsewhere that must pin this change before it
150
+ is final — check `prerelease` among the workflow's inputs: it publishes
151
+ `X.Y.Z-next.N` under the `next` dist-tag, from any branch, with no tag, no
152
+ release PR and no changelog entry.
142
153
 
143
154
  `publish` carries no npm token. It authenticates by **trusted publishing**:
144
155
  GitHub mints a short-lived credential for the job, npm checks it against the
@@ -150,12 +161,18 @@ The job refuses a missing credential by name, rather than failing on an opaque
150
161
  error from deep inside `npm publish`. There is no repository secret to add and
151
162
  no `.npmrc` anywhere.
152
163
 
153
- **If the publish job goes red after `npm publish` already ran, do not press
154
- retry.** npm refuses to republish a version — the resulting 403 reads like
155
- a broken run, not like a release that already happened. Check `npm view
156
- @fleetless/contracts@<version>` first.
157
-
158
- Removing a bad tag is an ordinary git operation here — nothing configures
159
- tag protection, so `git tag -d vX.Y.Z` locally and `git push origin
160
- :refs/tags/vX.Y.Z` remotely both just work. What that does *not* undo is a
161
- publish: the tag is retractable, the npm version is not.
164
+ **If the publish job goes red after `npm publish` already ran, run Release
165
+ again.** It sees that npm already has the version and does not publish
166
+ twice — npm refuses to republish a version, and a second attempt would only
167
+ read like a broken run. The rerun continues from there: it waits for the
168
+ registry to serve it, then finishes. Check `npm view
169
+ @fleetless/contracts@<version>` first if you want to see for yourself before
170
+ pressing anything.
171
+
172
+ Creating or deleting a `v*` tag is restricted — the organisation ruleset
173
+ `release-tags` allows only admins and the release App, which is how the
174
+ workflow tags a release without a maintainer pushing one by hand. An admin
175
+ can still remove a bad tag; ask one if you are not. What removing it does
176
+ *not* undo is a publish: the tag is retractable, the npm version is not —
177
+ and Release computes the next version from the newest tag, so deleting one
178
+ changes what a later run proposes.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "AUDIT_RETENTION_DAYS": 90,
3
- "PROTOCOL_VERSION": 3,
3
+ "PROTOCOL_VERSION": 4,
4
4
  "PROTOCOL_VERSIONS": [
5
5
  {
6
6
  "version": 2,
@@ -10,13 +10,21 @@
10
10
  {
11
11
  "version": 3,
12
12
  "bridge_from": "4.0.0",
13
+ "deprecated_at": "2026-09-29"
14
+ },
15
+ {
16
+ "version": 4,
17
+ "bridge_from": "5.0.0",
13
18
  "deprecated_at": null
14
19
  }
15
20
  ],
16
21
  "PROTOCOL_SUNSET_DAYS": 90,
17
- "LATEST_BRIDGE_VERSION": "4.0.0",
22
+ "LATEST_BRIDGE_VERSION": "5.0.0",
18
23
  "CLOSE_ROBOT_DELETED": 4004,
19
24
  "CLOSE_TOKEN_ROTATED": 4005,
25
+ "JOB_HEARTBEAT_INTERVAL_MS": 1000,
26
+ "JOB_HEARTBEAT_TIMEOUT_MS": 5000,
27
+ "JOB_OFFLINE_GRACE_MS": 300000,
20
28
  "ASSET_UPLOAD_HEADERS": {
21
29
  "kind": "x-fleetless-asset-kind",
22
30
  "name": "x-fleetless-asset-name",
@@ -13,6 +13,23 @@
13
13
  "format": "uuid",
14
14
  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
15
15
  }
16
+ },
17
+ "error": {
18
+ "type": "object",
19
+ "properties": {
20
+ "code": {
21
+ "type": "string",
22
+ "minLength": 1
23
+ },
24
+ "message": {
25
+ "type": "string",
26
+ "minLength": 1
27
+ }
28
+ },
29
+ "required": [
30
+ "code",
31
+ "message"
32
+ ]
16
33
  }
17
34
  },
18
35
  "required": [
@@ -13,6 +13,24 @@
13
13
  "format": "uuid",
14
14
  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
15
15
  }
16
+ },
17
+ "error": {
18
+ "type": "object",
19
+ "properties": {
20
+ "code": {
21
+ "type": "string",
22
+ "minLength": 1
23
+ },
24
+ "message": {
25
+ "type": "string",
26
+ "minLength": 1
27
+ }
28
+ },
29
+ "required": [
30
+ "code",
31
+ "message"
32
+ ],
33
+ "additionalProperties": false
16
34
  }
17
35
  },
18
36
  "required": [
package/dist/errors.d.ts CHANGED
@@ -49,5 +49,5 @@ export type ParameterInvalidDetails = z.infer<typeof parameterInvalidDetails>;
49
49
  * list is the shared vocabulary, not a closed set, so a new refusal never
50
50
  * needs a contracts release before it can be reported honestly.
51
51
  */
52
- export declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_slug", "unknown_field_path", "unknown_type", "unknown_topic", "invalid_rate", "invalid_range", "config_conflict", "no_data", "robot_offline", "bridge_timeout", "unauthorized", "forbidden", "invalid_credentials", "token_expired", "token_revoked", "email_taken", "identifier_taken", "weak_password", "account_blocked", "busy", "parameter_invalid", "job_lost", "publisher_busy", "unknown_command", "not_subscribable", "camera_offline", "no_snapshot_yet", "live_unavailable", "wrong_kind", "not_recorded", "not_aggregatable", "quota_exceeded", "credential_in_use", "goal_timeout", "robot_in_use", "robot_deletion_partial", "job_queue_full", "invalid_uuid", "rate_limited", "tier_required", "token_spent", "service_timeout", "asset_missing", "dynamic_registration_disabled", "client_limit_reached", "idp_unavailable", "mcp_disabled", "tool_not_available", "capability_required", "last_owner", "target_state_conflict", "signup_closed", "draft_not_a_document", "internal_error", "not_cancellable", "unsupported_media_type", "wrong_browser", "invalid_yaml", "unstorable_yaml", "registration_closed", "domain_not_allowed", "email_unverified", "origin_not_allowed", "template_invalid", "provider_disabled", "provider_misconfigured", "invalid_redirect_uri", "interaction_expired"];
52
+ export declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_slug", "unknown_field_path", "unknown_type", "unknown_topic", "invalid_rate", "invalid_range", "config_conflict", "no_data", "robot_offline", "bridge_timeout", "unauthorized", "forbidden", "invalid_credentials", "token_expired", "token_revoked", "email_taken", "identifier_taken", "weak_password", "account_blocked", "busy", "parameter_invalid", "job_lost", "action_server_lost", "action_failed", "goal_rejected", "goal_send_failed", "result_failed", "goal_uncontrollable", "bridge_disconnected", "config_changed", "publisher_busy", "unknown_command", "not_subscribable", "camera_offline", "no_snapshot_yet", "live_unavailable", "wrong_kind", "not_recorded", "not_aggregatable", "quota_exceeded", "credential_in_use", "goal_timeout", "robot_in_use", "robot_deletion_partial", "job_queue_full", "invalid_uuid", "rate_limited", "tier_required", "token_spent", "service_timeout", "asset_missing", "dynamic_registration_disabled", "client_limit_reached", "idp_unavailable", "mcp_disabled", "tool_not_available", "capability_required", "last_owner", "target_state_conflict", "signup_closed", "draft_not_a_document", "internal_error", "not_cancellable", "unsupported_media_type", "wrong_browser", "invalid_yaml", "unstorable_yaml", "registration_closed", "domain_not_allowed", "email_unverified", "origin_not_allowed", "template_invalid", "provider_disabled", "provider_misconfigured", "invalid_redirect_uri", "interaction_expired"];
53
53
  export type ErrorCode = (typeof ERROR_CODES)[number];
package/dist/errors.js CHANGED
@@ -71,6 +71,13 @@ export const ERROR_CODES = [
71
71
  'no_data',
72
72
  // Talking to the robot.
73
73
  'robot_offline',
74
+ /**
75
+ * The cloud has heard nothing — heartbeat or real progress — from a
76
+ * running job for longer than it tolerates while the bridge is connected:
77
+ * `patience_ms` for a protocol-3 bridge, `JOB_HEARTBEAT_TIMEOUT_MS` for a
78
+ * protocol-4 one once it has heard from the job at all. `job.error.code`
79
+ * on `lost`.
80
+ */
74
81
  'bridge_timeout',
75
82
  // Identity and rights. `forbidden` is deliberately the answer both
76
83
  // for "your role does not grant this" and for "there is no such slug":
@@ -140,6 +147,37 @@ export const ERROR_CODES = [
140
147
  'parameter_invalid',
141
148
  /** The bridge could not account for this job after a restart. */
142
149
  'job_lost',
150
+ /**
151
+ * The robot's action server vanished mid-goal — the bridge's own liveness
152
+ * check found `server_is_ready()` false for three seconds straight and
153
+ * gave up waiting for it to come back. A `job.error.code` on `lost`: the
154
+ * outcome the goal actually reached is unknown, so `lost` — not `failed` —
155
+ * is the honest state, and this code says why.
156
+ */
157
+ 'action_server_lost',
158
+ /** The action ended with a ROS status other than succeeded; `job.error.code` on `failed`. */
159
+ 'action_failed',
160
+ /** The action server rejected the goal outright; `job.error.code` on `failed`. */
161
+ 'goal_rejected',
162
+ /** Sending the goal to the action server itself raised; `job.error.code` on `failed`. */
163
+ 'goal_send_failed',
164
+ /** Asking the action server for its result raised; `job.error.code` on `failed`. */
165
+ 'result_failed',
166
+ /** A goal accepted after its own timeout could not then be cancelled; `job.error.code` on `lost`. */
167
+ 'goal_uncontrollable',
168
+ /**
169
+ * The robot stayed offline for longer than `JOB_OFFLINE_GRACE_MS` while a
170
+ * job was running. A late real outcome, if the robot reconnects and the
171
+ * bridge still has it, corrects this — it is not final the way a genuine
172
+ * bridge report is. `job.error.code` on `lost`.
173
+ */
174
+ 'bridge_disconnected',
175
+ /**
176
+ * The job's action or service no longer exists in the published
177
+ * configuration — a republish invalidated it while it was running.
178
+ * `job.error.code` on `cancelled`.
179
+ */
180
+ 'config_changed',
143
181
  /** Another user holds this publisher and has not been quiet long enough. */
144
182
  'publisher_busy',
145
183
  /** A well-formed realtime frame this server does not know — the socket stays open. */
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { SLUG_RULE, ROS_NAME_RULE, ROS_TYPE_NAME_RULE, FIELD_PATH_RULE, slug, ro
3
3
  export type { ApplyErrorKind, ApplyError } from './common.js';
4
4
  export { MCP_PROTOCOL_VERSION, MCP_ENDPOINT_PATH, mcpAppEndpointPath, MCP_APP_PATHS, MCP_TOOL_NAME_MAX, MCP_ASSET_LINK_PATH, MCP_ASSET_LINK_TTL_MS, mcpToolNamePattern, mcpToolKind, mcpExposure, mcpCapabilities, mcpRobotDatasheet, mcpRolePreviewResponse, } from './mcp.js';
5
5
  export type { McpAppPaths, McpToolKind, McpExposure, McpCapabilities, McpRobotDatasheet, McpRolePreviewResponse, } from './mcp.js';
6
- export { PROTOCOL_VERSION, PROTOCOL_VERSIONS, PROTOCOL_SUNSET_DAYS, LATEST_BRIDGE_VERSION, protocolStatus, minimumProtocolVersion, sunsetOf, statusFromTable, bridgeHello, cloudHelloOk, cloudHelloError, cloudPing, bridgePong, bridgeLinkMode, datapointFrame, bridgeState, cloudConfig, bridgeConfigApplied, cloudIntrospectRequest, bridgeIntrospect, cloudTypeRequest, bridgeTypeDefinitions, cloudInvoke, cloudCancel, cloudPublish, bridgeJobUpdate, bridgeJobLost, snapshotHeader, cloudCameraStart, cloudCameraStop, bridgeCameraState, SNAPSHOT_MAX_BYTES, CLOSE_ROBOT_DELETED, CLOSE_TOKEN_ROTATED, bridgeAssetsAvailable, cloudAssetRequest, bridgeAssetProgress, activeJob, DEFAULT_PATIENCE_MS, MAX_PATIENCE_MS, MIN_PATIENCE_MS, } from './protocol.js';
6
+ export { PROTOCOL_VERSION, PROTOCOL_VERSIONS, PROTOCOL_SUNSET_DAYS, LATEST_BRIDGE_VERSION, protocolStatus, minimumProtocolVersion, sunsetOf, statusFromTable, bridgeHello, cloudHelloOk, cloudHelloError, cloudPing, bridgePong, bridgeLinkMode, datapointFrame, bridgeState, cloudConfig, bridgeConfigApplied, cloudIntrospectRequest, bridgeIntrospect, cloudTypeRequest, bridgeTypeDefinitions, cloudInvoke, cloudCancel, cloudPublish, bridgeJobUpdate, bridgeJobLost, snapshotHeader, cloudCameraStart, cloudCameraStop, bridgeCameraState, SNAPSHOT_MAX_BYTES, CLOSE_ROBOT_DELETED, CLOSE_TOKEN_ROTATED, bridgeAssetsAvailable, cloudAssetRequest, bridgeAssetProgress, activeJob, DEFAULT_PATIENCE_MS, MAX_PATIENCE_MS, MIN_PATIENCE_MS, JOB_HEARTBEAT_INTERVAL_MS, JOB_HEARTBEAT_TIMEOUT_MS, JOB_OFFLINE_GRACE_MS, } from './protocol.js';
7
7
  export type { ProtocolVersionEntry, ProtocolStatus, BridgeHello, CloudHelloOk, CloudHelloError, CloudPing, BridgePong, BridgeLinkMode, DatapointFrame, BridgeState, CloudConfig, BridgeConfigApplied, CloudIntrospectRequest, BridgeIntrospect, CloudTypeRequest, BridgeTypeDefinitions, CloudInvoke, CloudCancel, CloudPublish, BridgeJobUpdate, BridgeJobLost, SnapshotHeader, CloudCameraStart, CloudCameraStop, BridgeCameraState, ActiveJob, BridgeAssetsAvailable, CloudAssetRequest, BridgeAssetProgress, } from './protocol.js';
8
8
  export { jobState, job, jobEvent, busyDetails, publisherBusyDetails, jobQueueFullDetails } from './jobs.js';
9
9
  export type { JobState, Job, JobEvent, BusyDetails, PublisherBusyDetails, JobQueueFullDetails, } from './jobs.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { PROTOCOL_VERSION, PROTOCOL_VERSIONS, PROTOCOL_SUNSET_DAYS, LATEST_BRIDG
5
5
  // Assets.
6
6
  bridgeAssetsAvailable, cloudAssetRequest, bridgeAssetProgress,
7
7
  // Addressing.
8
- activeJob, DEFAULT_PATIENCE_MS, MAX_PATIENCE_MS, MIN_PATIENCE_MS, } from './protocol.js';
8
+ activeJob, DEFAULT_PATIENCE_MS, MAX_PATIENCE_MS, MIN_PATIENCE_MS, JOB_HEARTBEAT_INTERVAL_MS, JOB_HEARTBEAT_TIMEOUT_MS, JOB_OFFLINE_GRACE_MS, } from './protocol.js';
9
9
  export { jobState, job, jobEvent, busyDetails, publisherBusyDetails, jobQueueFullDetails } from './jobs.js';
10
10
  export { JOB_RUN_PAGE_MAX, JOB_RUN_RETENTION_DAYS, jobActor, jobRunKind, jobRun, jobRunQuery, jobRunListResponse, jobRunSummaryQuery, jobRunSummary, } from './jobs.js';
11
11
  export { FLEETLESS_FORMAT_VERSION, RESERVED_SLUGS, parameterType, parameterSpec, parameterMap, serviceDescription, parameterDescription, messageTemplate, messageRef, messageBody, messageMap, PLACEHOLDER_RE, placeholderNames, actionConfig, serviceConfig, publisherConfig, cameraConfig, alertCondition, datapointAlert, rateThrottleHz, datapointNumeric, datapointRetention, datapointChart, datapointConfig, lowBandwidthSection, LOW_BANDWIDTH_DEFAULTS, robotConfigDoc, validationIssue, configState, snapshotIntervalSeconds,
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import { z } from 'zod';
3
3
  /**
4
- * Bridge <-> cloud protocol, version 3.
4
+ * Bridge <-> cloud protocol, version 4.
5
5
  *
6
6
  * The version is exchanged in the hello handshake. Since 2026-09 the cloud
7
7
  * serves a **window** of versions, not one: every entry of
@@ -11,6 +11,19 @@ import { z } from 'zod';
11
11
  * which names the window and reaches the robot's detail view as
12
12
  * `last_hello_error`.
13
13
  *
14
+ * **4 (2026-09-29):** the bridge sends a `job_update` heartbeat at
15
+ * `JOB_HEARTBEAT_INTERVAL_MS` for every running job, whether or not the
16
+ * action said anything new, and ends a job whose action server vanished
17
+ * `lost` with `action_server_lost` (a terminal `job_update`; `job_lost`
18
+ * gains an optional `error` for the same purpose). The cloud bounds
19
+ * a protocol-4 job's silence by the heartbeat (`JOB_HEARTBEAT_TIMEOUT_MS`)
20
+ * once it has heard from the job at all; `patience_ms` still bounds
21
+ * acceptance, the same as before. Offline tolerance is
22
+ * `JOB_OFFLINE_GRACE_MS` (five minutes), up from the informal one minute a
23
+ * protocol-3 bridge got. A protocol-3 bridge sends no heartbeat and keeps
24
+ * today's behaviour exactly: `patience_ms` alone bounds the whole running
25
+ * job, silence included.
26
+ *
14
27
  * **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
15
28
  * sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
16
29
  * `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
@@ -23,7 +36,7 @@ import { z } from 'zod';
23
36
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
24
37
  * beside `message`.
25
38
  */
26
- export declare const PROTOCOL_VERSION = 3;
39
+ export declare const PROTOCOL_VERSION = 4;
27
40
  /** Days between a version's deprecation and its sunset. */
28
41
  export declare const PROTOCOL_SUNSET_DAYS = 90;
29
42
  export interface ProtocolVersionEntry {
@@ -36,13 +49,15 @@ export interface ProtocolVersionEntry {
36
49
  /**
37
50
  * Every protocol version the cloud has served, oldest first. A test keeps
38
51
  * 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.
52
+ * requires some CHANGELOG section — `[Unreleased]` or a dated one — to name
53
+ * the newest `bridge_from` together with the previous entry's `sunsetOf(...)`
54
+ * date, so the pull request that moves this window is the one that fails
55
+ * without saying so; and `scripts/verify-version-tag.mjs` requires a dated
56
+ * heading for the tag being released.
42
57
  */
43
58
  export declare const PROTOCOL_VERSIONS: readonly ProtocolVersionEntry[];
44
59
  /** The newest bridge package. The cloud mails organisations still below it. */
45
- export declare const LATEST_BRIDGE_VERSION = "4.0.0";
60
+ export declare const LATEST_BRIDGE_VERSION = "5.0.0";
46
61
  export interface ProtocolStatus {
47
62
  status: 'current' | 'deprecated' | 'unsupported';
48
63
  /** ISO date, or null for a current or unknown version. */
@@ -128,6 +143,40 @@ export declare const MAX_PATIENCE_MS = 120000;
128
143
  * this end bounds what the caller can do to the robot.
129
144
  */
130
145
  export declare const MIN_PATIENCE_MS = 1000;
146
+ /**
147
+ * How often a protocol-4 bridge sends a `job_update` heartbeat for every
148
+ * running job — the last known state, whether or not the action itself said
149
+ * anything new. One second: often enough that `JOB_HEARTBEAT_TIMEOUT_MS`
150
+ * can be a small multiple of it and still absorb a missed beat or two, rare
151
+ * enough that it costs nothing next to the datapoint traffic a busy robot
152
+ * already sends.
153
+ */
154
+ export declare const JOB_HEARTBEAT_INTERVAL_MS = 1000;
155
+ /**
156
+ * How long a protocol-4 job may go without a `job_update` — heartbeat or
157
+ * real progress, either counts — before the cloud settles it `lost` with
158
+ * `bridge_timeout`, once the bridge is connected. Five heartbeats: enough
159
+ * slack for an ordinary scheduling jitter, small next to `patience_ms`
160
+ * because it no longer has to cover the acceptance gap too. `patience_ms`
161
+ * bounds only the time from `invoke` to the *first* update on a protocol-4
162
+ * job; every rearm after that uses this constant instead. A protocol-3
163
+ * bridge sends no heartbeat, so this constant does not apply to it —
164
+ * `patience_ms` keeps bounding the whole running job there, exactly as
165
+ * before.
166
+ */
167
+ export declare const JOB_HEARTBEAT_TIMEOUT_MS = 5000;
168
+ /**
169
+ * How long a running job survives its robot going offline before the cloud
170
+ * gives up and settles it `lost` with `bridge_disconnected`. Five minutes:
171
+ * long enough that an ordinary Wi-Fi dead zone — the case this constant
172
+ * exists for — never costs a job, since a robot with no safety layer of its
173
+ * own (§ Fleetless is not a safety layer) keeps driving through one and the
174
+ * result the cloud is waiting for is often still coming. A robot connected
175
+ * the whole time never reaches this bound at all: while online, silence is
176
+ * `JOB_HEARTBEAT_TIMEOUT_MS`'s question (protocol 4) or `patience_ms`'s
177
+ * (protocol 3), never this one's.
178
+ */
179
+ export declare const JOB_OFFLINE_GRACE_MS = 300000;
131
180
  /** Re-exported so consumers keep importing wire names from one place. */
132
181
  export { slug } from './common.js';
133
182
  /**
@@ -598,10 +647,23 @@ export type BridgeJobUpdate = z.infer<typeof bridgeJobUpdate>;
598
647
  * left to enumerate, so it is `hello.active_job_ids` that closes that gap.
599
648
  * Both paths end in the same place — the cloud publishes `lost` rather than
600
649
  * leaving a job reading "running" because nobody contradicted it.
650
+ *
651
+ * **`error` (since protocol 4) is optional and, when present, applies to
652
+ * every job named in `job_ids`.** A vanished action server is discovered
653
+ * once, by the bridge's own liveness check on that one goal, so a frame
654
+ * naming several jobs at once — plausible if several goals shared the same
655
+ * server — always shares the same cause. Absent means today's behaviour:
656
+ * the cloud settles the job `lost` with no specific code, the same as a
657
+ * protocol-3 bridge's frame, which carries no `error` at all and still
658
+ * parses under this schema unchanged.
601
659
  */
602
660
  export declare const bridgeJobLost: z.ZodObject<{
603
661
  type: z.ZodLiteral<"job_lost">;
604
662
  job_ids: z.ZodArray<z.ZodUUID>;
663
+ error: z.ZodOptional<z.ZodObject<{
664
+ code: z.ZodString;
665
+ message: z.ZodString;
666
+ }, z.core.$strip>>;
605
667
  }, z.core.$strip>;
606
668
  export type BridgeJobLost = z.infer<typeof bridgeJobLost>;
607
669
  /** Cloud asks for a fresh ROS graph; `request_id` correlates the answer. */
package/dist/protocol.js CHANGED
@@ -7,7 +7,7 @@ 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 3.
10
+ * Bridge <-> cloud protocol, version 4.
11
11
  *
12
12
  * The version is exchanged in the hello handshake. Since 2026-09 the cloud
13
13
  * serves a **window** of versions, not one: every entry of
@@ -17,6 +17,19 @@ import { rosTypeName } from './common.js';
17
17
  * which names the window and reaches the robot's detail view as
18
18
  * `last_hello_error`.
19
19
  *
20
+ * **4 (2026-09-29):** the bridge sends a `job_update` heartbeat at
21
+ * `JOB_HEARTBEAT_INTERVAL_MS` for every running job, whether or not the
22
+ * action said anything new, and ends a job whose action server vanished
23
+ * `lost` with `action_server_lost` (a terminal `job_update`; `job_lost`
24
+ * gains an optional `error` for the same purpose). The cloud bounds
25
+ * a protocol-4 job's silence by the heartbeat (`JOB_HEARTBEAT_TIMEOUT_MS`)
26
+ * once it has heard from the job at all; `patience_ms` still bounds
27
+ * acceptance, the same as before. Offline tolerance is
28
+ * `JOB_OFFLINE_GRACE_MS` (five minutes), up from the informal one minute a
29
+ * protocol-3 bridge got. A protocol-3 bridge sends no heartbeat and keeps
30
+ * today's behaviour exactly: `patience_ms` alone bounds the whole running
31
+ * job, silence included.
32
+ *
20
33
  * **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
21
34
  * sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
22
35
  * `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
@@ -29,22 +42,25 @@ import { rosTypeName } from './common.js';
29
42
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
30
43
  * beside `message`.
31
44
  */
32
- export const PROTOCOL_VERSION = 3;
45
+ export const PROTOCOL_VERSION = 4;
33
46
  /** Days between a version's deprecation and its sunset. */
34
47
  export const PROTOCOL_SUNSET_DAYS = 90;
35
48
  /**
36
49
  * Every protocol version the cloud has served, oldest first. A test keeps
37
50
  * 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.
51
+ * requires some CHANGELOG section — `[Unreleased]` or a dated one — to name
52
+ * the newest `bridge_from` together with the previous entry's `sunsetOf(...)`
53
+ * date, so the pull request that moves this window is the one that fails
54
+ * without saying so; and `scripts/verify-version-tag.mjs` requires a dated
55
+ * heading for the tag being released.
41
56
  */
42
57
  export const PROTOCOL_VERSIONS = [
43
58
  { version: 2, bridge_from: '3.0.0', deprecated_at: '2026-09-22' },
44
- { version: 3, bridge_from: '4.0.0', deprecated_at: null },
59
+ { version: 3, bridge_from: '4.0.0', deprecated_at: '2026-09-29' },
60
+ { version: 4, bridge_from: '5.0.0', deprecated_at: null },
45
61
  ];
46
62
  /** The newest bridge package. The cloud mails organisations still below it. */
47
- export const LATEST_BRIDGE_VERSION = '4.0.0';
63
+ export const LATEST_BRIDGE_VERSION = '5.0.0';
48
64
  const DAY_MS = 24 * 60 * 60 * 1000;
49
65
  function isoDate(date) {
50
66
  return date.toISOString().slice(0, 10);
@@ -146,6 +162,40 @@ export const MAX_PATIENCE_MS = 120_000;
146
162
  * this end bounds what the caller can do to the robot.
147
163
  */
148
164
  export const MIN_PATIENCE_MS = 1_000;
165
+ /**
166
+ * How often a protocol-4 bridge sends a `job_update` heartbeat for every
167
+ * running job — the last known state, whether or not the action itself said
168
+ * anything new. One second: often enough that `JOB_HEARTBEAT_TIMEOUT_MS`
169
+ * can be a small multiple of it and still absorb a missed beat or two, rare
170
+ * enough that it costs nothing next to the datapoint traffic a busy robot
171
+ * already sends.
172
+ */
173
+ export const JOB_HEARTBEAT_INTERVAL_MS = 1_000;
174
+ /**
175
+ * How long a protocol-4 job may go without a `job_update` — heartbeat or
176
+ * real progress, either counts — before the cloud settles it `lost` with
177
+ * `bridge_timeout`, once the bridge is connected. Five heartbeats: enough
178
+ * slack for an ordinary scheduling jitter, small next to `patience_ms`
179
+ * because it no longer has to cover the acceptance gap too. `patience_ms`
180
+ * bounds only the time from `invoke` to the *first* update on a protocol-4
181
+ * job; every rearm after that uses this constant instead. A protocol-3
182
+ * bridge sends no heartbeat, so this constant does not apply to it —
183
+ * `patience_ms` keeps bounding the whole running job there, exactly as
184
+ * before.
185
+ */
186
+ export const JOB_HEARTBEAT_TIMEOUT_MS = 5_000;
187
+ /**
188
+ * How long a running job survives its robot going offline before the cloud
189
+ * gives up and settles it `lost` with `bridge_disconnected`. Five minutes:
190
+ * long enough that an ordinary Wi-Fi dead zone — the case this constant
191
+ * exists for — never costs a job, since a robot with no safety layer of its
192
+ * own (§ Fleetless is not a safety layer) keeps driving through one and the
193
+ * result the cloud is waiting for is often still coming. A robot connected
194
+ * the whole time never reaches this bound at all: while online, silence is
195
+ * `JOB_HEARTBEAT_TIMEOUT_MS`'s question (protocol 4) or `patience_ms`'s
196
+ * (protocol 3), never this one's.
197
+ */
198
+ export const JOB_OFFLINE_GRACE_MS = 300_000;
149
199
  /** Re-exported so consumers keep importing wire names from one place. */
150
200
  export { slug } from './common.js';
151
201
  /**
@@ -440,10 +490,20 @@ export const bridgeJobUpdate = z.object({
440
490
  * left to enumerate, so it is `hello.active_job_ids` that closes that gap.
441
491
  * Both paths end in the same place — the cloud publishes `lost` rather than
442
492
  * leaving a job reading "running" because nobody contradicted it.
493
+ *
494
+ * **`error` (since protocol 4) is optional and, when present, applies to
495
+ * every job named in `job_ids`.** A vanished action server is discovered
496
+ * once, by the bridge's own liveness check on that one goal, so a frame
497
+ * naming several jobs at once — plausible if several goals shared the same
498
+ * server — always shares the same cause. Absent means today's behaviour:
499
+ * the cloud settles the job `lost` with no specific code, the same as a
500
+ * protocol-3 bridge's frame, which carries no `error` at all and still
501
+ * parses under this schema unchanged.
443
502
  */
444
503
  export const bridgeJobLost = z.object({
445
504
  type: z.literal('job_lost'),
446
505
  job_ids: z.array(z.uuid()),
506
+ error: z.object({ code: z.string().min(1), message: z.string().min(1) }).optional(),
447
507
  });
448
508
  /** Cloud asks for a fresh ROS graph; `request_id` correlates the answer. */
449
509
  export const cloudIntrospectRequest = z.object({
@@ -256,8 +256,8 @@ export declare const liveSessionEndReason: z.ZodEnum<{
256
256
  unknown: "unknown";
257
257
  publish_failed: "publish_failed";
258
258
  robot_offline: "robot_offline";
259
- released_by_peer: "released_by_peer";
260
259
  config_changed: "config_changed";
260
+ released_by_peer: "released_by_peer";
261
261
  revoked: "revoked";
262
262
  expired: "expired";
263
263
  robot_deleted: "robot_deleted";
@@ -287,8 +287,8 @@ export declare const liveSessionEvent: z.ZodObject<{
287
287
  unknown: "unknown";
288
288
  publish_failed: "publish_failed";
289
289
  robot_offline: "robot_offline";
290
- released_by_peer: "released_by_peer";
291
290
  config_changed: "config_changed";
291
+ released_by_peer: "released_by_peer";
292
292
  revoked: "revoked";
293
293
  expired: "expired";
294
294
  robot_deleted: "robot_deleted";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetless/contracts",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Fleetless wire contracts: the bridge-cloud protocol, the REST API schemas and the error codes, as zod schemas with generated JSON Schema and OpenAPI artifacts.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dehne Robotik GmbH",