Skip to main content

sapphire_framework_bridge_api/
lib.rs

1//! What an app server says to the bridge, and how it says it.
2//!
3//! Kept separate from `sapphire-framework-bridge` so that an app server can talk to the
4//! bridge without linking iroh: Cargo unifies features across a workspace build, so a
5//! feature flag on one crate would not have been enough.
6//!
7//! See `docs/superpowers/specs/2026-09-16-process-architecture-design.md` §4.3 and §5.
8
9#![warn(missing_docs)]
10
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15pub use grain_id::GrainId;
16
17/// How an app server process came to exist, as it tells the bridge in [`REGISTER`].
18///
19/// The same shape as `sapphire_ipc::ManagedBy`, defined here so this crate does not
20/// depend on the transport.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ManagedBy {
24    /// Started by the OS service manager. The bridge never starts it.
25    Service,
26    /// Started on demand.
27    Spawned,
28}
29
30mod embed;
31pub use embed::{
32    ApiKey, DEFAULT_DIMENSION, DEFAULT_MAX_TOKENS, DeviceSettings, EmbedDeviceSetParams,
33    EmbedKeySetParams, EmbedModelSetParams, EmbedNote, EmbedRequest, EmbedSettingsResult,
34    LOCAL_MODEL, LocalModel, MAX_TOKENS, ModelSettings, ModelSource, RemoteModel, Slot, SlotModel,
35    describe,
36};
37
38mod external;
39pub use external::{
40    ExternalDeviceAddParams, ExternalDeviceAuthenticateParams, ExternalDeviceAuthenticateResult,
41    ExternalDeviceInfo, ExternalDeviceListResult, ExternalDeviceOutcome, ExternalDeviceRequest,
42    ExternalDeviceSelectParams, ExternalDeviceSetAppsParams, ExternalDeviceTokenResult,
43    describe_external_device, describe_external_outcome,
44};
45
46#[cfg(feature = "cli")]
47mod cli;
48#[cfg(feature = "cli")]
49pub use cli::{
50    EmbeddingCommand, ExternalDeviceCommand, KeyCommand, LocalCommand, RemoteCommand, Switch,
51    read_key,
52};
53
54/// The version of the bridge's control-plane API: the methods below and their types.
55///
56/// It is this crate's major version, parsed at compile time, so the two cannot drift: a
57/// breaking change to a method, a parameter or a result here is a major release of this
58/// crate, and that release is the new API version. The handshake compares this number.
59pub const API_VERSION: u32 = parse_major(env!("CARGO_PKG_VERSION_MAJOR"));
60
61/// `CARGO_PKG_VERSION_MAJOR` as a number. Cargo guarantees it is decimal digits.
62const fn parse_major(s: &str) -> u32 {
63    let bytes = s.as_bytes();
64    let mut i = 0;
65    let mut n = 0u32;
66    while i < bytes.len() {
67        n = n * 10 + (bytes[i] - b'0') as u32;
68        i += 1;
69    }
70    n
71}
72
73/// The endpoint name the bridge's control plane listens under.
74pub const BRIDGE_NAME: &str = "bridge";
75/// The endpoint name the bridge's data plane listens under.
76pub const BRIDGE_DATA_NAME: &str = "bridge-data";
77/// The application-layer protocol name used on every peer connection.
78pub const ALPN: &[u8] = b"sapphire/ws/1";
79
80/// Announce which workspaces this app server owns.
81pub const REGISTER: &str = "bridge.register";
82/// Stop owning one workspace.
83pub const UNREGISTER: &str = "bridge.unregister";
84/// List the workgroup's devices and whether they are connected.
85pub const PEERS: &str = "bridge.peers";
86/// Describe the bridge.
87pub const STATUS: &str = "bridge.status";
88/// Notification: a peer wants a workspace this app server owns.
89pub const INCOMING: &str = "bridge.incoming";
90/// Create an invite, and get the ticket to hand to the joining device.
91pub const INVITE: &str = "bridge.invite";
92/// Join the workgroup a ticket names.
93pub const JOIN: &str = "bridge.join";
94/// List the workspaces the workgroup knows about.
95pub const WORKSPACES: &str = "bridge.workspaces";
96/// Found a workgroup on this host, as its first device.
97pub const WORKGROUP_CREATE: &str = "bridge.workgroup_create";
98/// Retire a device of this host's workgroup.
99pub const DEVICE_RETIRE: &str = "bridge.device_retire";
100/// Ask the bridge which embedding model it serves, if any.
101pub const EMBED_INFO: &str = "embed.info";
102/// Embed texts with the bridge's embedding model.
103pub const EMBED: &str = "embed.embed";
104/// List the workgroup's external devices.
105pub const EXTERNAL_DEVICE_LIST: &str = "external_device.list";
106/// Add an external device; the answer carries its token, once.
107pub const EXTERNAL_DEVICE_ADD: &str = "external_device.add";
108/// Retire an external device: its token stops working, the record stays.
109pub const EXTERNAL_DEVICE_RETIRE: &str = "external_device.retire";
110/// Bring a retired external device back, with its token.
111pub const EXTERNAL_DEVICE_RESTORE: &str = "external_device.restore";
112/// Replace an external device's token, keeping its id.
113pub const EXTERNAL_DEVICE_ROTATE: &str = "external_device.rotate";
114/// Set the applications an external device may use.
115pub const EXTERNAL_DEVICE_SET_APPS: &str = "external_device.set_apps";
116/// Check a presented token for an application: who it is, or an error.
117pub const EXTERNAL_DEVICE_AUTHENTICATE: &str = "external_device.authenticate";
118/// Read the embedding settings and what they resolve to.
119pub const EMBED_SETTINGS: &str = "embed.settings";
120/// Set or clear one model slot.
121pub const EMBED_MODEL_SET: &str = "embed.model_set";
122/// Switch one slot on or off on this device.
123pub const EMBED_DEVICE_SET: &str = "embed.device_set";
124/// Store the remote slot's API key on this device.
125pub const EMBED_KEY_SET: &str = "embed.key_set";
126/// Remove the remote slot's API key from this device.
127pub const EMBED_KEY_CLEAR: &str = "embed.key_clear";
128
129/// Set a device's election priority.
130pub const DEVICE_PRIORITY_SET: &str = "bridge.device_priority_set";
131
132/// A device's priority when its record names none. Mirrors the registry's own constant;
133/// this crate does not depend on the registry.
134pub const DEFAULT_PRIORITY: u8 = 1;
135
136fn default_priority() -> u8 {
137    DEFAULT_PRIORITY
138}
139
140/// Parameters of [`DEVICE_PRIORITY_SET`].
141#[derive(Clone, Debug, Deserialize, Serialize)]
142pub struct DevicePrioritySetParams {
143    /// The device's name or id.
144    pub selector: String,
145    /// `0..=255`. `0` takes the device out of the election.
146    pub priority: u8,
147}
148
149/// Result of [`DEVICE_PRIORITY_SET`].
150#[derive(Clone, Debug, Deserialize, Serialize)]
151pub struct DevicePrioritySetResult {
152    /// The device's id.
153    pub device_id: GrainId,
154    /// Its name.
155    pub name: String,
156    /// Its priority now.
157    pub priority: u8,
158}
159
160/// Parameters of [`INVITE`].
161#[derive(Clone, Debug, Deserialize, Serialize)]
162pub struct InviteParams {
163    /// What the joining device will be called in the ledger.
164    pub name: String,
165    /// How long the invite stays good, in seconds. `None` uses the bridge's default.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub ttl: Option<u64>,
168    /// The workgroup to invite into, by name or id. `None` uses this host's only one.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub workgroup: Option<String>,
171}
172
173/// Result of [`INVITE`].
174#[derive(Clone, Debug, Deserialize, Serialize)]
175pub struct InviteResult {
176    /// The ticket, in the text form a user copies to the joining device.
177    pub ticket: String,
178}
179
180/// Parameters of [`WORKGROUP_CREATE`].
181#[derive(Clone, Debug, Deserialize, Serialize)]
182pub struct WorkgroupCreateParams {
183    /// The workgroup's name.
184    pub name: String,
185    /// This host's device name inside it.
186    pub device_name: String,
187}
188
189/// Result of [`WORKGROUP_CREATE`].
190#[derive(Clone, Debug, Deserialize, Serialize)]
191pub struct WorkgroupCreateResult {
192    /// The new workgroup's id.
193    pub workgroup_id: GrainId,
194    /// Its name.
195    pub name: String,
196    /// This host's device id in it.
197    pub device_id: GrainId,
198}
199
200/// Parameters of [`DEVICE_RETIRE`].
201#[derive(Clone, Debug, Deserialize, Serialize)]
202pub struct DeviceRetireParams {
203    /// The device's name or id.
204    pub selector: String,
205}
206
207/// Result of [`DEVICE_RETIRE`].
208#[derive(Clone, Debug, Deserialize, Serialize)]
209pub struct DeviceRetireResult {
210    /// The retired device's id.
211    pub device_id: GrainId,
212    /// Its name.
213    pub name: String,
214}
215
216/// Parameters of [`JOIN`].
217#[derive(Clone, Debug, Deserialize, Serialize)]
218pub struct JoinParams {
219    /// The ticket the inviter produced.
220    pub ticket: String,
221    /// The name this device will carry in the ledger. `None` uses the host name.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub device_name: Option<String>,
224}
225
226/// Result of [`JOIN`].
227#[derive(Clone, Debug, Deserialize, Serialize)]
228pub struct JoinResult {
229    /// The workgroup that was joined.
230    pub workgroup_id: GrainId,
231    /// Its name, as the founding device chose it.
232    pub workgroup_name: String,
233    /// This device's own record inside it.
234    pub device_id: GrainId,
235}
236
237/// One workspace the workgroup knows about.
238#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
239pub struct WorkgroupWorkspaceInfo {
240    /// Its identity across devices.
241    pub workspace_id: GrainId,
242    /// The application that owns it.
243    pub app_name: String,
244    /// The name the workgroup lists it under.
245    pub name: String,
246}
247
248/// Result of [`WORKSPACES`].
249#[derive(Clone, Debug, Deserialize, Serialize)]
250pub struct WorkspacesResult {
251    /// Every workspace the workgroup knows about.
252    pub workspaces: Vec<WorkgroupWorkspaceInfo>,
253}
254
255/// The success payload of a call that returns nothing.
256#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
257pub struct Ack {}
258
259/// One workspace an app server owns.
260#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
261pub struct WorkspaceRegistration {
262    /// The workspace's sync identity, shared across devices.
263    pub workspace_id: GrainId,
264    /// Where it lives on this host.
265    pub root: PathBuf,
266}
267
268/// Parameters of [`REGISTER`].
269#[derive(Clone, Debug, Deserialize, Serialize)]
270pub struct RegisterParams {
271    /// Which application this server belongs to.
272    pub app_name: String,
273    /// The executable to run when a peer wants a workspace and this server is not running.
274    pub exe_path: PathBuf,
275    /// How this server was started. A `Service` server is never started by the bridge.
276    pub managed_by: ManagedBy,
277    /// The workspaces it owns. Registering again replaces the previous list for this app.
278    pub workspaces: Vec<WorkspaceRegistration>,
279}
280
281/// Result of [`REGISTER`].
282#[derive(Clone, Debug, Deserialize, Serialize)]
283pub struct RegisterResult {
284    /// This host's device id inside the workgroup, used as `Entry.author`.
285    pub device_id: GrainId,
286    /// This host's iroh node id.
287    pub node_id: String,
288    /// The workgroup these workspaces belong to.
289    pub workgroup_id: GrainId,
290}
291
292/// Parameters of [`UNREGISTER`].
293#[derive(Clone, Debug, Deserialize, Serialize)]
294pub struct UnregisterParams {
295    /// The workspace to stop owning.
296    pub workspace_id: GrainId,
297}
298
299/// Who the bridge elected for one workspace this host's app servers own.
300#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
301pub struct WorkspaceRoles {
302    /// The workspace.
303    pub workspace_id: GrainId,
304    /// The primary device, if any candidate exists.
305    #[serde(default)]
306    pub primary: Option<GrainId>,
307    /// The secondary device, if a second candidate exists.
308    #[serde(default)]
309    pub secondary: Option<GrainId>,
310}
311
312/// One device of the workgroup.
313#[derive(Clone, Debug, Deserialize, Serialize)]
314pub struct PeerInfo {
315    /// Its device id.
316    pub device_id: GrainId,
317    /// Its name.
318    pub name: String,
319    /// Its iroh node id.
320    pub node_id: String,
321    /// Whether the bridge currently holds a connection to it.
322    pub connected: bool,
323    /// Its election priority, as its ledger record says.
324    #[serde(default = "default_priority")]
325    pub priority: u8,
326    /// Its availability tier, as its own Hello reports it (#190). `None` until measured.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub availability: Option<u8>,
329}
330
331/// Result of [`PEERS`].
332#[derive(Clone, Debug, Deserialize, Serialize)]
333pub struct PeersResult {
334    /// Every non-retired device of the workgroup, this host included.
335    pub peers: Vec<PeerInfo>,
336    /// The elected roles of every workspace this host's app servers own. Empty from a
337    /// bridge older than 2.1, which means a full mesh.
338    #[serde(default)]
339    pub roles: Vec<WorkspaceRoles>,
340}
341
342impl PeersResult {
343    /// The roles for `workspace_id`, if the bridge elected any.
344    pub fn roles_for(&self, workspace_id: GrainId) -> Option<&WorkspaceRoles> {
345        self.roles.iter().find(|r| r.workspace_id == workspace_id)
346    }
347}
348
349/// One row of the routing table.
350#[derive(Clone, Debug, Deserialize, Serialize)]
351pub struct RouteStatus {
352    /// The workspace.
353    pub workspace_id: GrainId,
354    /// The application that owns it.
355    pub app_name: String,
356    /// Where it lives on this host.
357    pub root: PathBuf,
358    /// Whether that application's server is connected right now.
359    pub owner_online: bool,
360}
361
362/// The workgroup this host belongs to.
363#[derive(Clone, Debug, Deserialize, Serialize)]
364pub struct WorkgroupStatus {
365    /// Its id.
366    pub workgroup_id: GrainId,
367    /// Its name.
368    pub name: String,
369    /// How many non-retired devices it has.
370    pub devices: usize,
371}
372
373/// The embedding model a bridge serves.
374#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
375pub struct EmbedModelInfo {
376    /// The model's name.
377    pub model: String,
378    /// Its vector dimension.
379    pub dimension: u32,
380    /// The version of the text template applied before embedding.
381    pub template_version: u32,
382    /// The model's pinned revision, when the bridge pins one.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub revision: Option<String>,
385    /// Content tokens kept before embedding, when the bridge truncates (the local model).
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub max_tokens: Option<u32>,
388}
389
390/// Result of [`EMBED_INFO`].
391#[derive(Clone, Debug, Default, Deserialize, Serialize)]
392pub struct EmbedInfoResult {
393    /// Whether this bridge embeds at all.
394    pub enabled: bool,
395    /// The model, when enabled.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub model: Option<EmbedModelInfo>,
398    /// Whether the model is in memory right now (local provider); always true for REST.
399    #[serde(default)]
400    pub loaded: bool,
401    /// Why embedding is off, or what it lacks while on. Absent from bridges before 2.3.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub note: Option<EmbedNote>,
404}
405
406/// Parameters of [`EMBED`].
407#[derive(Clone, Debug, Deserialize, Serialize)]
408pub struct EmbedParams {
409    /// The texts to embed.
410    pub texts: Vec<String>,
411}
412
413/// Result of [`EMBED`].
414#[derive(Clone, Debug, Deserialize, Serialize)]
415pub struct EmbedResult {
416    /// The model that produced the vectors.
417    pub model: String,
418    /// Their dimension.
419    pub dimension: u32,
420    /// One vector per input text, in order.
421    pub vectors: Vec<Vec<f32>>,
422}
423
424/// Result of [`STATUS`].
425#[derive(Clone, Debug, Deserialize, Serialize)]
426pub struct StatusResult {
427    /// The bridge's version.
428    pub version: String,
429    /// This host's iroh node id.
430    pub node_id: String,
431    /// The workgroup, if this host has joined one.
432    pub workgroup: Option<WorkgroupStatus>,
433    /// Every registered workspace.
434    pub routes: Vec<RouteStatus>,
435    /// The embedding service, when this bridge reports one.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub embedding: Option<EmbedInfoResult>,
438}
439
440/// Parameters of the [`INCOMING`] notification.
441#[derive(Clone, Debug, Deserialize, Serialize)]
442pub struct IncomingParams {
443    /// The workspace the peer asked for.
444    pub workspace_id: GrainId,
445    /// Which device asked.
446    pub peer_device_id: GrainId,
447    /// A single-use token naming the waiting stream.
448    ///
449    /// Useless to anyone who did not receive this notification, and consumed the first time
450    /// it is presented.
451    pub ticket: String,
452}
453
454/// The first line of a data-plane connection.
455#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
456#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
457pub enum DataHeader {
458    /// The app server wants a stream to `device_id` for `workspace_id`.
459    Open {
460        /// The workspace being synced.
461        workspace_id: GrainId,
462        /// The peer to reach.
463        device_id: GrainId,
464    },
465    /// The app server is answering a [`INCOMING`] notification.
466    Accept {
467        /// The ticket from that notification.
468        ticket: String,
469    },
470}
471
472/// The bridge's answer to a [`DataHeader`], sent as one line before the raw bytes begin.
473#[derive(Clone, Debug, Deserialize, Serialize)]
474pub struct DataAck {
475    /// Whether the stream is open.
476    pub ok: bool,
477    /// Why not, when it is not.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub error: Option<String>,
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(v: &T) -> T {
487        serde_json::from_value(serde_json::to_value(v).unwrap()).unwrap()
488    }
489
490    fn id() -> GrainId {
491        GrainId::random()
492    }
493
494    #[test]
495    fn embed_info_round_trips() {
496        let info = EmbedInfoResult {
497            enabled: true,
498            model: Some(EmbedModelInfo {
499                model: "m".into(),
500                dimension: 384,
501                template_version: 1,
502                revision: None,
503                max_tokens: None,
504            }),
505            loaded: true,
506            note: Some(EmbedNote::KeyMissing),
507        };
508        let back = round_trip(&info);
509        assert_eq!(back.note, Some(EmbedNote::KeyMissing));
510        assert!(back.enabled && back.loaded);
511        assert_eq!(back.model, info.model);
512        let off = serde_json::to_value(EmbedInfoResult::default()).unwrap();
513        assert_eq!(off, serde_json::json!({"enabled": false, "loaded": false}));
514    }
515
516    #[test]
517    fn status_without_embedding_deserializes() {
518        let json = serde_json::json!({
519            "version": "1", "node_id": "n", "workgroup": null, "routes": []
520        });
521        let status: StatusResult = serde_json::from_value(json).unwrap();
522        assert!(status.embedding.is_none());
523    }
524
525    #[test]
526    fn embed_params_wire_shape() {
527        let v = serde_json::to_value(EmbedParams {
528            texts: vec!["a".into()],
529        })
530        .unwrap();
531        assert_eq!(v, serde_json::json!({"texts": ["a"]}));
532        let r = round_trip(&EmbedResult {
533            model: "m".into(),
534            dimension: 2,
535            vectors: vec![vec![1.0, 2.0]],
536        });
537        assert_eq!(r.vectors, vec![vec![1.0, 2.0]]);
538    }
539
540    #[test]
541    fn registration_round_trips() {
542        let params = RegisterParams {
543            app_name: "sapphire-journal".into(),
544            exe_path: "/usr/bin/sapphire-journal".into(),
545            managed_by: ManagedBy::Spawned,
546            workspaces: vec![WorkspaceRegistration {
547                workspace_id: id(),
548                root: "/home/me/journal".into(),
549            }],
550        };
551        let back = round_trip(&params);
552        assert_eq!(back.app_name, "sapphire-journal");
553        assert_eq!(back.workspaces.len(), 1);
554    }
555
556    #[test]
557    fn invite_params_carry_the_cli_arguments() {
558        let params = InviteParams {
559            name: "phone".into(),
560            ttl: Some(600),
561            workgroup: None,
562        };
563        // Serialisation shape is the contract with the bridge's handler; pin it.
564        let json = serde_json::to_value(&params).unwrap();
565        assert_eq!(json["name"], "phone");
566        assert_eq!(json["ttl"], 600);
567        assert!(json.get("workgroup").is_none());
568    }
569
570    #[test]
571    fn workspaces_result_round_trips() {
572        let raw = serde_json::json!({ "workspaces": [] });
573        let parsed: WorkspacesResult = serde_json::from_value(raw).unwrap();
574        assert!(parsed.workspaces.is_empty());
575    }
576
577    #[test]
578    fn an_open_header_round_trips_and_is_tagged() {
579        let header = DataHeader::Open {
580            workspace_id: id(),
581            device_id: id(),
582        };
583        let value = serde_json::to_value(&header).unwrap();
584        assert_eq!(value["kind"], "open");
585        assert_eq!(round_trip(&header), header);
586    }
587
588    #[test]
589    fn an_accept_header_round_trips_and_is_tagged() {
590        let header = DataHeader::Accept {
591            ticket: "t-123".into(),
592        };
593        let value = serde_json::to_value(&header).unwrap();
594        assert_eq!(value["kind"], "accept");
595        assert_eq!(round_trip(&header), header);
596    }
597
598    #[test]
599    fn an_unknown_header_kind_is_refused() {
600        let value = serde_json::json!({ "kind": "sideways" });
601        assert!(serde_json::from_value::<DataHeader>(value).is_err());
602    }
603
604    #[test]
605    fn a_data_ack_carries_its_reason_when_it_fails() {
606        let ack = DataAck {
607            ok: false,
608            error: Some("no such workspace".into()),
609        };
610        assert_eq!(round_trip(&ack).error.as_deref(), Some("no such workspace"));
611    }
612
613    #[test]
614    fn method_names_are_namespaced() {
615        for name in [
616            REGISTER, UNREGISTER, PEERS, STATUS, INCOMING, INVITE, JOIN, WORKSPACES,
617        ] {
618            assert!(name.starts_with("bridge."), "{name}");
619        }
620    }
621
622    #[test]
623    fn the_crate_stays_free_of_the_workspace_and_network_stacks() {
624        let manifest = include_str!("../Cargo.toml");
625        for forbidden in [
626            "iroh",
627            "sapphire-framework-workspace",
628            "sapphire-framework-retrieve",
629            "sapphire-framework-backend",
630        ] {
631            assert!(
632                !manifest.contains(forbidden),
633                "sapphire-framework-bridge-api must not depend on {forbidden}"
634            );
635        }
636    }
637
638    #[test]
639    fn the_api_version_is_the_crate_major() {
640        let major: u32 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap();
641        assert_eq!(API_VERSION, major);
642        assert_eq!(API_VERSION, 2);
643    }
644
645    #[test]
646    fn workgroup_create_and_device_retire_wire_shapes() {
647        let p = WorkgroupCreateParams {
648            name: "home".into(),
649            device_name: "desk".into(),
650        };
651        assert_eq!(
652            serde_json::to_value(&p).unwrap(),
653            serde_json::json!({ "name": "home", "device_name": "desk" })
654        );
655        let p = DeviceRetireParams {
656            selector: "laptop".into(),
657        };
658        assert_eq!(
659            serde_json::to_value(&p).unwrap(),
660            serde_json::json!({ "selector": "laptop" })
661        );
662        let id = GrainId::random();
663        let r = WorkgroupCreateResult {
664            workgroup_id: id,
665            name: "home".into(),
666            device_id: id,
667        };
668        assert_eq!(round_trip(&r).name, "home");
669        assert_eq!(WORKGROUP_CREATE, "bridge.workgroup_create");
670        assert_eq!(DEVICE_RETIRE, "bridge.device_retire");
671    }
672
673    #[test]
674    fn a_2_0_peers_answer_reads_with_no_roles_and_default_priority() {
675        let id = GrainId::random();
676        let old = serde_json::json!({
677            "peers": [{ "device_id": id, "name": "desk", "node_id": "", "connected": true }]
678        });
679
680        let read: PeersResult = serde_json::from_value(old).unwrap();
681
682        assert!(read.roles.is_empty());
683        assert_eq!(read.peers[0].priority, DEFAULT_PRIORITY);
684        assert_eq!(read.peers[0].availability, None);
685        assert!(read.roles_for(id).is_none());
686    }
687
688    #[test]
689    fn roles_for_finds_the_workspace() {
690        let ws = GrainId::random();
691        let d = GrainId::random();
692        let result = PeersResult {
693            peers: Vec::new(),
694            roles: vec![WorkspaceRoles {
695                workspace_id: ws,
696                primary: Some(d),
697                secondary: None,
698            }],
699        };
700
701        assert_eq!(result.roles_for(ws).unwrap().primary, Some(d));
702    }
703
704    #[test]
705    fn device_priority_set_params_are_selector_and_priority() {
706        let p = DevicePrioritySetParams {
707            selector: "desk".into(),
708            priority: 0,
709        };
710        assert_eq!(
711            serde_json::to_value(&p).unwrap(),
712            serde_json::json!({ "selector": "desk", "priority": 0 })
713        );
714    }
715}