@fleetless/contracts 3.0.0 → 4.0.0-next.1

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,29 @@
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
+ ### Added
13
+
14
+ - **Protocol 4: a job heartbeat, and a vanished action server ends its job.**
15
+ Protocol bumped: bridges from `4.1.0`, and protocol 3 sunsets
16
+ 2026-12-27 (protocol 2 sunset 2026-12-21). The bridge now sends a
17
+ `job_update` heartbeat every `JOB_HEARTBEAT_INTERVAL_MS` for every running
18
+ job; `job_lost` gains an optional `error`, so a bridge that finds its
19
+ action server gone can say `action_server_lost` instead of leaving the
20
+ cloud to guess. `JOB_HEARTBEAT_TIMEOUT_MS` bounds a protocol-4 job's
21
+ silence once it has been heard from at all; `patience_ms` now bounds only
22
+ the acceptance gap on such a job (unchanged for protocol 3, which sends no
23
+ heartbeat). `JOB_OFFLINE_GRACE_MS` (five minutes) replaces the informal
24
+ one-minute disconnect grace a job got before. `errors.ts` documents
25
+ `action_server_lost` and every job error code already in use that had
26
+ never been written down: `action_failed`, `goal_rejected`,
27
+ `goal_send_failed`, `result_failed`, `goal_uncontrollable`,
28
+ `bridge_disconnected`, `config_changed`.
7
29
 
8
30
  ## [3.0.0] — 2026-09-22
9
31
 
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-28"
14
+ },
15
+ {
16
+ "version": 4,
17
+ "bridge_from": "4.1.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": "4.1.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 `failed`. */
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,18 @@ 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-28):** 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 reports a vanished action server with
17
+ * `job_lost`'s new optional `error`, `action_server_lost`. The cloud bounds
18
+ * a protocol-4 job's silence by the heartbeat (`JOB_HEARTBEAT_TIMEOUT_MS`)
19
+ * once it has heard from the job at all; `patience_ms` still bounds
20
+ * acceptance, the same as before. Offline tolerance is
21
+ * `JOB_OFFLINE_GRACE_MS` (five minutes), up from the informal one minute a
22
+ * protocol-3 bridge got. A protocol-3 bridge sends no heartbeat and keeps
23
+ * today's behaviour exactly: `patience_ms` alone bounds the whole running
24
+ * job, silence included.
25
+ *
14
26
  * **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
15
27
  * sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
16
28
  * `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
@@ -23,7 +35,7 @@ import { z } from 'zod';
23
35
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
24
36
  * beside `message`.
25
37
  */
26
- export declare const PROTOCOL_VERSION = 3;
38
+ export declare const PROTOCOL_VERSION = 4;
27
39
  /** Days between a version's deprecation and its sunset. */
28
40
  export declare const PROTOCOL_SUNSET_DAYS = 90;
29
41
  export interface ProtocolVersionEntry {
@@ -36,13 +48,15 @@ export interface ProtocolVersionEntry {
36
48
  /**
37
49
  * Every protocol version the cloud has served, oldest first. A test keeps
38
50
  * 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.
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.
42
56
  */
43
57
  export declare const PROTOCOL_VERSIONS: readonly ProtocolVersionEntry[];
44
58
  /** The newest bridge package. The cloud mails organisations still below it. */
45
- export declare const LATEST_BRIDGE_VERSION = "4.0.0";
59
+ export declare const LATEST_BRIDGE_VERSION = "4.1.0";
46
60
  export interface ProtocolStatus {
47
61
  status: 'current' | 'deprecated' | 'unsupported';
48
62
  /** ISO date, or null for a current or unknown version. */
@@ -128,6 +142,40 @@ export declare const MAX_PATIENCE_MS = 120000;
128
142
  * this end bounds what the caller can do to the robot.
129
143
  */
130
144
  export declare const MIN_PATIENCE_MS = 1000;
145
+ /**
146
+ * How often a protocol-4 bridge sends a `job_update` heartbeat for every
147
+ * running job — the last known state, whether or not the action itself said
148
+ * anything new. One second: often enough that `JOB_HEARTBEAT_TIMEOUT_MS`
149
+ * can be a small multiple of it and still absorb a missed beat or two, rare
150
+ * enough that it costs nothing next to the datapoint traffic a busy robot
151
+ * already sends.
152
+ */
153
+ export declare const JOB_HEARTBEAT_INTERVAL_MS = 1000;
154
+ /**
155
+ * How long a protocol-4 job may go without a `job_update` — heartbeat or
156
+ * real progress, either counts — before the cloud settles it `lost` with
157
+ * `bridge_timeout`, once the bridge is connected. Five heartbeats: enough
158
+ * slack for an ordinary scheduling jitter, small next to `patience_ms`
159
+ * because it no longer has to cover the acceptance gap too. `patience_ms`
160
+ * bounds only the time from `invoke` to the *first* update on a protocol-4
161
+ * job; every rearm after that uses this constant instead. A protocol-3
162
+ * bridge sends no heartbeat, so this constant does not apply to it —
163
+ * `patience_ms` keeps bounding the whole running job there, exactly as
164
+ * before.
165
+ */
166
+ export declare const JOB_HEARTBEAT_TIMEOUT_MS = 5000;
167
+ /**
168
+ * How long a running job survives its robot going offline before the cloud
169
+ * gives up and settles it `lost` with `bridge_disconnected`. Five minutes:
170
+ * long enough that an ordinary Wi-Fi dead zone — the case this constant
171
+ * exists for — never costs a job, since a robot with no safety layer of its
172
+ * own (§ Fleetless is not a safety layer) keeps driving through one and the
173
+ * result the cloud is waiting for is often still coming. A robot connected
174
+ * the whole time never reaches this bound at all: while online, silence is
175
+ * `JOB_HEARTBEAT_TIMEOUT_MS`'s question (protocol 4) or `patience_ms`'s
176
+ * (protocol 3), never this one's.
177
+ */
178
+ export declare const JOB_OFFLINE_GRACE_MS = 300000;
131
179
  /** Re-exported so consumers keep importing wire names from one place. */
132
180
  export { slug } from './common.js';
133
181
  /**
@@ -598,10 +646,23 @@ export type BridgeJobUpdate = z.infer<typeof bridgeJobUpdate>;
598
646
  * left to enumerate, so it is `hello.active_job_ids` that closes that gap.
599
647
  * Both paths end in the same place — the cloud publishes `lost` rather than
600
648
  * leaving a job reading "running" because nobody contradicted it.
649
+ *
650
+ * **`error` (since protocol 4) is optional and, when present, applies to
651
+ * every job named in `job_ids`.** A vanished action server is discovered
652
+ * once, by the bridge's own liveness check on that one goal, so a frame
653
+ * naming several jobs at once — plausible if several goals shared the same
654
+ * server — always shares the same cause. Absent means today's behaviour:
655
+ * the cloud settles the job `lost` with no specific code, the same as a
656
+ * protocol-3 bridge's frame, which carries no `error` at all and still
657
+ * parses under this schema unchanged.
601
658
  */
602
659
  export declare const bridgeJobLost: z.ZodObject<{
603
660
  type: z.ZodLiteral<"job_lost">;
604
661
  job_ids: z.ZodArray<z.ZodUUID>;
662
+ error: z.ZodOptional<z.ZodObject<{
663
+ code: z.ZodString;
664
+ message: z.ZodString;
665
+ }, z.core.$strip>>;
605
666
  }, z.core.$strip>;
606
667
  export type BridgeJobLost = z.infer<typeof bridgeJobLost>;
607
668
  /** 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,18 @@ 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-28):** 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 reports a vanished action server with
23
+ * `job_lost`'s new optional `error`, `action_server_lost`. The cloud bounds
24
+ * a protocol-4 job's silence by the heartbeat (`JOB_HEARTBEAT_TIMEOUT_MS`)
25
+ * once it has heard from the job at all; `patience_ms` still bounds
26
+ * acceptance, the same as before. Offline tolerance is
27
+ * `JOB_OFFLINE_GRACE_MS` (five minutes), up from the informal one minute a
28
+ * protocol-3 bridge got. A protocol-3 bridge sends no heartbeat and keeps
29
+ * today's behaviour exactly: `patience_ms` alone bounds the whole running
30
+ * job, silence included.
31
+ *
20
32
  * **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
21
33
  * sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
22
34
  * `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
@@ -29,22 +41,25 @@ import { rosTypeName } from './common.js';
29
41
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
30
42
  * beside `message`.
31
43
  */
32
- export const PROTOCOL_VERSION = 3;
44
+ export const PROTOCOL_VERSION = 4;
33
45
  /** Days between a version's deprecation and its sunset. */
34
46
  export const PROTOCOL_SUNSET_DAYS = 90;
35
47
  /**
36
48
  * Every protocol version the cloud has served, oldest first. A test keeps
37
49
  * 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.
50
+ * requires some CHANGELOG section — `[Unreleased]` or a dated one — to name
51
+ * the newest `bridge_from` together with the previous entry's `sunsetOf(...)`
52
+ * date, so the pull request that moves this window is the one that fails
53
+ * without saying so; and `scripts/verify-version-tag.mjs` requires a dated
54
+ * heading for the tag being released.
41
55
  */
42
56
  export const PROTOCOL_VERSIONS = [
43
57
  { version: 2, bridge_from: '3.0.0', deprecated_at: '2026-09-22' },
44
- { version: 3, bridge_from: '4.0.0', deprecated_at: null },
58
+ { version: 3, bridge_from: '4.0.0', deprecated_at: '2026-09-28' },
59
+ { version: 4, bridge_from: '4.1.0', deprecated_at: null },
45
60
  ];
46
61
  /** The newest bridge package. The cloud mails organisations still below it. */
47
- export const LATEST_BRIDGE_VERSION = '4.0.0';
62
+ export const LATEST_BRIDGE_VERSION = '4.1.0';
48
63
  const DAY_MS = 24 * 60 * 60 * 1000;
49
64
  function isoDate(date) {
50
65
  return date.toISOString().slice(0, 10);
@@ -146,6 +161,40 @@ export const MAX_PATIENCE_MS = 120_000;
146
161
  * this end bounds what the caller can do to the robot.
147
162
  */
148
163
  export const MIN_PATIENCE_MS = 1_000;
164
+ /**
165
+ * How often a protocol-4 bridge sends a `job_update` heartbeat for every
166
+ * running job — the last known state, whether or not the action itself said
167
+ * anything new. One second: often enough that `JOB_HEARTBEAT_TIMEOUT_MS`
168
+ * can be a small multiple of it and still absorb a missed beat or two, rare
169
+ * enough that it costs nothing next to the datapoint traffic a busy robot
170
+ * already sends.
171
+ */
172
+ export const JOB_HEARTBEAT_INTERVAL_MS = 1_000;
173
+ /**
174
+ * How long a protocol-4 job may go without a `job_update` — heartbeat or
175
+ * real progress, either counts — before the cloud settles it `lost` with
176
+ * `bridge_timeout`, once the bridge is connected. Five heartbeats: enough
177
+ * slack for an ordinary scheduling jitter, small next to `patience_ms`
178
+ * because it no longer has to cover the acceptance gap too. `patience_ms`
179
+ * bounds only the time from `invoke` to the *first* update on a protocol-4
180
+ * job; every rearm after that uses this constant instead. A protocol-3
181
+ * bridge sends no heartbeat, so this constant does not apply to it —
182
+ * `patience_ms` keeps bounding the whole running job there, exactly as
183
+ * before.
184
+ */
185
+ export const JOB_HEARTBEAT_TIMEOUT_MS = 5_000;
186
+ /**
187
+ * How long a running job survives its robot going offline before the cloud
188
+ * gives up and settles it `lost` with `bridge_disconnected`. Five minutes:
189
+ * long enough that an ordinary Wi-Fi dead zone — the case this constant
190
+ * exists for — never costs a job, since a robot with no safety layer of its
191
+ * own (§ Fleetless is not a safety layer) keeps driving through one and the
192
+ * result the cloud is waiting for is often still coming. A robot connected
193
+ * the whole time never reaches this bound at all: while online, silence is
194
+ * `JOB_HEARTBEAT_TIMEOUT_MS`'s question (protocol 4) or `patience_ms`'s
195
+ * (protocol 3), never this one's.
196
+ */
197
+ export const JOB_OFFLINE_GRACE_MS = 300_000;
149
198
  /** Re-exported so consumers keep importing wire names from one place. */
150
199
  export { slug } from './common.js';
151
200
  /**
@@ -440,10 +489,20 @@ export const bridgeJobUpdate = z.object({
440
489
  * left to enumerate, so it is `hello.active_job_ids` that closes that gap.
441
490
  * Both paths end in the same place — the cloud publishes `lost` rather than
442
491
  * leaving a job reading "running" because nobody contradicted it.
492
+ *
493
+ * **`error` (since protocol 4) is optional and, when present, applies to
494
+ * every job named in `job_ids`.** A vanished action server is discovered
495
+ * once, by the bridge's own liveness check on that one goal, so a frame
496
+ * naming several jobs at once — plausible if several goals shared the same
497
+ * server — always shares the same cause. Absent means today's behaviour:
498
+ * the cloud settles the job `lost` with no specific code, the same as a
499
+ * protocol-3 bridge's frame, which carries no `error` at all and still
500
+ * parses under this schema unchanged.
443
501
  */
444
502
  export const bridgeJobLost = z.object({
445
503
  type: z.literal('job_lost'),
446
504
  job_ids: z.array(z.uuid()),
505
+ error: z.object({ code: z.string().min(1), message: z.string().min(1) }).optional(),
447
506
  });
448
507
  /** Cloud asks for a fresh ROS graph; `request_id` correlates the answer. */
449
508
  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-next.1",
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",