1#![warn(missing_docs)]
10
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15pub use grain_id::GrainId;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ManagedBy {
24 Service,
26 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
54pub const API_VERSION: u32 = parse_major(env!("CARGO_PKG_VERSION_MAJOR"));
60
61const 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
73pub const BRIDGE_NAME: &str = "bridge";
75pub const BRIDGE_DATA_NAME: &str = "bridge-data";
77pub const ALPN: &[u8] = b"sapphire/ws/1";
79
80pub const REGISTER: &str = "bridge.register";
82pub const UNREGISTER: &str = "bridge.unregister";
84pub const PEERS: &str = "bridge.peers";
86pub const STATUS: &str = "bridge.status";
88pub const INCOMING: &str = "bridge.incoming";
90pub const INVITE: &str = "bridge.invite";
92pub const JOIN: &str = "bridge.join";
94pub const WORKSPACES: &str = "bridge.workspaces";
96pub const WORKGROUP_CREATE: &str = "bridge.workgroup_create";
98pub const DEVICE_RETIRE: &str = "bridge.device_retire";
100pub const EMBED_INFO: &str = "embed.info";
102pub const EMBED: &str = "embed.embed";
104pub const EXTERNAL_DEVICE_LIST: &str = "external_device.list";
106pub const EXTERNAL_DEVICE_ADD: &str = "external_device.add";
108pub const EXTERNAL_DEVICE_RETIRE: &str = "external_device.retire";
110pub const EXTERNAL_DEVICE_RESTORE: &str = "external_device.restore";
112pub const EXTERNAL_DEVICE_ROTATE: &str = "external_device.rotate";
114pub const EXTERNAL_DEVICE_SET_APPS: &str = "external_device.set_apps";
116pub const EXTERNAL_DEVICE_AUTHENTICATE: &str = "external_device.authenticate";
118pub const EMBED_SETTINGS: &str = "embed.settings";
120pub const EMBED_MODEL_SET: &str = "embed.model_set";
122pub const EMBED_DEVICE_SET: &str = "embed.device_set";
124pub const EMBED_KEY_SET: &str = "embed.key_set";
126pub const EMBED_KEY_CLEAR: &str = "embed.key_clear";
128
129pub const DEVICE_PRIORITY_SET: &str = "bridge.device_priority_set";
131
132pub const DEFAULT_PRIORITY: u8 = 1;
135
136fn default_priority() -> u8 {
137 DEFAULT_PRIORITY
138}
139
140#[derive(Clone, Debug, Deserialize, Serialize)]
142pub struct DevicePrioritySetParams {
143 pub selector: String,
145 pub priority: u8,
147}
148
149#[derive(Clone, Debug, Deserialize, Serialize)]
151pub struct DevicePrioritySetResult {
152 pub device_id: GrainId,
154 pub name: String,
156 pub priority: u8,
158}
159
160#[derive(Clone, Debug, Deserialize, Serialize)]
162pub struct InviteParams {
163 pub name: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub ttl: Option<u64>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub workgroup: Option<String>,
171}
172
173#[derive(Clone, Debug, Deserialize, Serialize)]
175pub struct InviteResult {
176 pub ticket: String,
178}
179
180#[derive(Clone, Debug, Deserialize, Serialize)]
182pub struct WorkgroupCreateParams {
183 pub name: String,
185 pub device_name: String,
187}
188
189#[derive(Clone, Debug, Deserialize, Serialize)]
191pub struct WorkgroupCreateResult {
192 pub workgroup_id: GrainId,
194 pub name: String,
196 pub device_id: GrainId,
198}
199
200#[derive(Clone, Debug, Deserialize, Serialize)]
202pub struct DeviceRetireParams {
203 pub selector: String,
205}
206
207#[derive(Clone, Debug, Deserialize, Serialize)]
209pub struct DeviceRetireResult {
210 pub device_id: GrainId,
212 pub name: String,
214}
215
216#[derive(Clone, Debug, Deserialize, Serialize)]
218pub struct JoinParams {
219 pub ticket: String,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub device_name: Option<String>,
224}
225
226#[derive(Clone, Debug, Deserialize, Serialize)]
228pub struct JoinResult {
229 pub workgroup_id: GrainId,
231 pub workgroup_name: String,
233 pub device_id: GrainId,
235}
236
237#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
239pub struct WorkgroupWorkspaceInfo {
240 pub workspace_id: GrainId,
242 pub app_name: String,
244 pub name: String,
246}
247
248#[derive(Clone, Debug, Deserialize, Serialize)]
250pub struct WorkspacesResult {
251 pub workspaces: Vec<WorkgroupWorkspaceInfo>,
253}
254
255#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
257pub struct Ack {}
258
259#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
261pub struct WorkspaceRegistration {
262 pub workspace_id: GrainId,
264 pub root: PathBuf,
266}
267
268#[derive(Clone, Debug, Deserialize, Serialize)]
270pub struct RegisterParams {
271 pub app_name: String,
273 pub exe_path: PathBuf,
275 pub managed_by: ManagedBy,
277 pub workspaces: Vec<WorkspaceRegistration>,
279}
280
281#[derive(Clone, Debug, Deserialize, Serialize)]
283pub struct RegisterResult {
284 pub device_id: GrainId,
286 pub node_id: String,
288 pub workgroup_id: GrainId,
290}
291
292#[derive(Clone, Debug, Deserialize, Serialize)]
294pub struct UnregisterParams {
295 pub workspace_id: GrainId,
297}
298
299#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
301pub struct WorkspaceRoles {
302 pub workspace_id: GrainId,
304 #[serde(default)]
306 pub primary: Option<GrainId>,
307 #[serde(default)]
309 pub secondary: Option<GrainId>,
310}
311
312#[derive(Clone, Debug, Deserialize, Serialize)]
314pub struct PeerInfo {
315 pub device_id: GrainId,
317 pub name: String,
319 pub node_id: String,
321 pub connected: bool,
323 #[serde(default = "default_priority")]
325 pub priority: u8,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub availability: Option<u8>,
329}
330
331#[derive(Clone, Debug, Deserialize, Serialize)]
333pub struct PeersResult {
334 pub peers: Vec<PeerInfo>,
336 #[serde(default)]
339 pub roles: Vec<WorkspaceRoles>,
340}
341
342impl PeersResult {
343 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#[derive(Clone, Debug, Deserialize, Serialize)]
351pub struct RouteStatus {
352 pub workspace_id: GrainId,
354 pub app_name: String,
356 pub root: PathBuf,
358 pub owner_online: bool,
360}
361
362#[derive(Clone, Debug, Deserialize, Serialize)]
364pub struct WorkgroupStatus {
365 pub workgroup_id: GrainId,
367 pub name: String,
369 pub devices: usize,
371}
372
373#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
375pub struct EmbedModelInfo {
376 pub model: String,
378 pub dimension: u32,
380 pub template_version: u32,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub revision: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub max_tokens: Option<u32>,
388}
389
390#[derive(Clone, Debug, Default, Deserialize, Serialize)]
392pub struct EmbedInfoResult {
393 pub enabled: bool,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub model: Option<EmbedModelInfo>,
398 #[serde(default)]
400 pub loaded: bool,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub note: Option<EmbedNote>,
404}
405
406#[derive(Clone, Debug, Deserialize, Serialize)]
408pub struct EmbedParams {
409 pub texts: Vec<String>,
411}
412
413#[derive(Clone, Debug, Deserialize, Serialize)]
415pub struct EmbedResult {
416 pub model: String,
418 pub dimension: u32,
420 pub vectors: Vec<Vec<f32>>,
422}
423
424#[derive(Clone, Debug, Deserialize, Serialize)]
426pub struct StatusResult {
427 pub version: String,
429 pub node_id: String,
431 pub workgroup: Option<WorkgroupStatus>,
433 pub routes: Vec<RouteStatus>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub embedding: Option<EmbedInfoResult>,
438}
439
440#[derive(Clone, Debug, Deserialize, Serialize)]
442pub struct IncomingParams {
443 pub workspace_id: GrainId,
445 pub peer_device_id: GrainId,
447 pub ticket: String,
452}
453
454#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
456#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
457pub enum DataHeader {
458 Open {
460 workspace_id: GrainId,
462 device_id: GrainId,
464 },
465 Accept {
467 ticket: String,
469 },
470}
471
472#[derive(Clone, Debug, Deserialize, Serialize)]
474pub struct DataAck {
475 pub ok: bool,
477 #[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(¶ms);
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 let json = serde_json::to_value(¶ms).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}