qualia_client_core/identity_plane/
fleet.rs1use super::device::{DeviceRecord, DeviceRecordPublic};
4use super::person::{PersonPrincipal, PersonPublic, PersonTransferBundle};
5use crate::node_identity::NodeIdentity;
6use crate::setup::DeviceContext;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10const FLEET_FORMAT: &str = "qualia.device_fleet.v1";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13struct DeviceFleetFile {
14 format: String,
15 person_id: String,
16 local_device_id: String,
17 devices: Vec<DeviceRecord>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct IdentityPlaneSnapshot {
23 pub person: PersonPublic,
24 pub local_device_id: String,
25 pub devices: Vec<DeviceRecordPublic>,
26 pub os_account_is_not_principal: bool,
28 pub notes: Vec<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case", tag = "placement")]
34pub enum JobPlacement {
35 Local { device_id: String },
37 RemoteRegistered { device_id: String, label: String },
39 Unknown { device_id: String },
41}
42
43fn fleet_path() -> PathBuf {
44 crate::state::app_meta_dir().join("device_fleet.json")
45}
46
47fn load_fleet() -> Result<Option<DeviceFleetFile>, String> {
48 let path = fleet_path();
49 if !path.exists() {
50 return Ok(None);
51 }
52 let bytes =
53 std::fs::read(&path).map_err(|e| format!("failed to read {}: {e}", path.display()))?;
54 let fleet: DeviceFleetFile =
55 serde_json::from_slice(&bytes).map_err(|e| format!("failed to parse device fleet: {e}"))?;
56 Ok(Some(fleet))
57}
58
59fn save_fleet(fleet: &DeviceFleetFile) -> Result<(), String> {
60 let path = fleet_path();
61 if let Some(parent) = path.parent() {
62 std::fs::create_dir_all(parent)
63 .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
64 }
65 let json = serde_json::to_string_pretty(fleet)
66 .map_err(|e| format!("failed to encode device fleet: {e}"))?;
67 std::fs::write(&path, json).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
68 Ok(())
69}
70
71pub fn ensure_local_apparatus(
75 device_context: Option<DeviceContext>,
76) -> Result<IdentityPlaneSnapshot, String> {
77 let mut person = PersonPrincipal::load_or_create(None)?;
78 if let Ok(setup) = crate::setup::get_setup_state() {
79 if !setup.profile.preferred_name.trim().is_empty() && person.display_hint.trim().is_empty()
80 {
81 person.display_hint = setup.profile.preferred_name.clone();
82 person.persist()?;
83 }
84 }
85
86 let node = NodeIdentity::load_or_create()?;
87 let pubkey = node.identity_pubkey_hex();
88 let ctx = device_context.unwrap_or_else(|| {
89 crate::setup::get_setup_state()
90 .map(|s| s.profile.device_context)
91 .unwrap_or_default()
92 });
93
94 let label = if !ctx.notes.trim().is_empty() {
95 ctx.notes.chars().take(48).collect::<String>()
96 } else {
97 let host = std::env::var("COMPUTERNAME")
98 .or_else(|_| std::env::var("HOSTNAME"))
99 .unwrap_or_else(|_| "this machine".into());
100 format!("Webizen on {host}")
101 };
102
103 let mut local = DeviceRecord::new_local(&person.person_id, pubkey, ctx, label);
104 if local.control_base_url.is_empty() {
106 if let Some(port) = crate::state::APP_STATE
107 .get()
108 .and_then(|s| s.config.lock().ok())
109 .map(|c| c.settings_port)
110 {
111 if port > 0 {
112 local.control_base_url = format!("http://127.0.0.1:{port}");
113 }
114 }
115 }
116 local.touch();
117
118 let fleet = match load_fleet()? {
119 Some(mut f) => {
120 f.person_id = person.person_id.clone();
121 f.local_device_id = local.device_id.clone();
122 if let Some(slot) = f
124 .devices
125 .iter_mut()
126 .find(|d| d.device_id == local.device_id)
127 {
128 slot.device_context = local.device_context.clone();
129 slot.label = local.label.clone();
130 slot.hostname = local.hostname.clone();
131 slot.is_local = true;
132 slot.person_id = person.person_id.clone();
133 slot.identity_pubkey_hex = local.identity_pubkey_hex.clone();
134 slot.capabilities = local.capabilities.clone();
135 if !local.control_base_url.is_empty() {
136 let existing = slot.control_base_url.clone();
138 if existing.is_empty()
139 || existing.contains("127.0.0.1")
140 || existing.contains("localhost")
141 {
142 slot.control_base_url = local.control_base_url.clone();
143 }
144 }
145 slot.touch();
146 } else {
147 for d in f.devices.iter_mut() {
149 d.is_local = false;
150 }
151 f.devices.push(local.clone());
152 }
153 for d in f.devices.iter_mut() {
155 if d.is_local {
156 d.person_id = person.person_id.clone();
157 }
158 }
159 f.format = FLEET_FORMAT.to_string();
160 f
161 }
162 None => DeviceFleetFile {
163 format: FLEET_FORMAT.to_string(),
164 person_id: person.person_id.clone(),
165 local_device_id: local.device_id.clone(),
166 devices: vec![local],
167 },
168 };
169
170 save_fleet(&fleet)?;
171 Ok(snapshot_from(person.to_public(), &fleet))
172}
173
174fn snapshot_from(person: PersonPublic, fleet: &DeviceFleetFile) -> IdentityPlaneSnapshot {
175 IdentityPlaneSnapshot {
176 person,
177 local_device_id: fleet.local_device_id.clone(),
178 devices: fleet.devices.clone(),
179 os_account_is_not_principal: true,
180 notes: vec![
181 "The person principal is not the machine and not the OS login.".into(),
182 "Each Qualia install is a separate apparatus (device) under the person.".into(),
183 "Import a person transfer bundle on another machine to share the same person principal.".into(),
184 "Jobs may name a target device_id; only the local apparatus runs work in this build.".into(),
185 ],
186 }
187}
188
189pub fn get_identity_plane() -> Result<IdentityPlaneSnapshot, String> {
190 ensure_local_apparatus(None)
191}
192
193pub fn list_devices() -> Result<Vec<DeviceRecordPublic>, String> {
194 Ok(get_identity_plane()?.devices)
195}
196
197pub fn sync_local_device_context(
198 device_context: &DeviceContext,
199) -> Result<IdentityPlaneSnapshot, String> {
200 ensure_local_apparatus(Some(device_context.clone()))
201}
202
203pub fn export_person_public() -> Result<PersonPublic, String> {
204 Ok(PersonPrincipal::load_or_create(None)?.to_public())
205}
206
207pub fn export_person_transfer_bundle() -> Result<PersonTransferBundle, String> {
209 Ok(PersonPrincipal::load_or_create(None)?.transfer_bundle())
210}
211
212pub fn import_person_transfer_bundle(
214 bundle: PersonTransferBundle,
215) -> Result<IdentityPlaneSnapshot, String> {
216 PersonPrincipal::from_transfer_bundle(bundle)?;
217 ensure_local_apparatus(None)
219}
220
221pub fn register_remote_device(
223 mut device: DeviceRecordPublic,
224) -> Result<IdentityPlaneSnapshot, String> {
225 if !device.device_id.starts_with("did:q42:device:") {
226 return Err("device_id must be did:q42:device:…".into());
227 }
228 let plane = ensure_local_apparatus(None)?;
229 if device.device_id == plane.local_device_id {
230 return Err("cannot register the local apparatus as a remote device".into());
231 }
232 device.is_local = false;
233 if device.person_id.trim().is_empty() {
234 device.person_id = plane.person.person_id.clone();
235 }
236 device.touch();
237
238 let mut fleet = load_fleet()?.ok_or("device fleet missing after ensure")?;
239 if let Some(slot) = fleet
240 .devices
241 .iter_mut()
242 .find(|d| d.device_id == device.device_id)
243 {
244 *slot = device;
245 } else {
246 fleet.devices.push(device);
247 }
248 save_fleet(&fleet)?;
249 Ok(snapshot_from(plane.person, &fleet))
250}
251
252pub fn set_local_control_base_url(url: impl Into<String>) -> Result<IdentityPlaneSnapshot, String> {
254 let mut plane = ensure_local_apparatus(None)?;
255 let url = url.into().trim().trim_end_matches('/').to_string();
256 let mut fleet = load_fleet()?.ok_or("device fleet missing")?;
257 if let Some(slot) = fleet
258 .devices
259 .iter_mut()
260 .find(|d| d.device_id == fleet.local_device_id)
261 {
262 slot.control_base_url = url;
263 slot.touch();
264 }
265 save_fleet(&fleet)?;
266 plane = snapshot_from(plane.person, &fleet);
267 Ok(plane)
268}
269
270pub fn resolve_job_placement(target_device_id: Option<&str>) -> Result<JobPlacement, String> {
271 let plane = ensure_local_apparatus(None)?;
272 let Some(target) = target_device_id.map(str::trim).filter(|s| !s.is_empty()) else {
273 return Ok(JobPlacement::Local {
274 device_id: plane.local_device_id,
275 });
276 };
277 if target == plane.local_device_id {
278 return Ok(JobPlacement::Local {
279 device_id: plane.local_device_id,
280 });
281 }
282 if let Some(dev) = plane.devices.iter().find(|d| d.device_id == target) {
283 if dev.is_local {
284 return Ok(JobPlacement::Local {
285 device_id: plane.local_device_id,
286 });
287 }
288 return Ok(JobPlacement::RemoteRegistered {
289 device_id: dev.device_id.clone(),
290 label: dev.label.clone(),
291 });
292 }
293 Ok(JobPlacement::Unknown {
294 device_id: target.to_string(),
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::setup::DeviceContext;
302 use std::sync::Mutex;
303
304 static META_LOCK: Mutex<()> = Mutex::new(());
306
307 #[test]
308 fn placement_defaults_to_local_shape() {
309 let p = JobPlacement::Local {
310 device_id: "did:q42:device:aa".into(),
311 };
312 assert!(matches!(p, JobPlacement::Local { .. }));
313 let r = JobPlacement::RemoteRegistered {
314 device_id: "did:q42:device:bb".into(),
315 label: "laptop".into(),
316 };
317 assert!(matches!(r, JobPlacement::RemoteRegistered { .. }));
318 let _ = DeviceContext::default();
319 }
320
321 #[test]
322 fn ensure_local_separates_person_from_device_and_os() {
323 let _g = META_LOCK.lock().unwrap();
324 let dir = tempfile::tempdir().unwrap();
325 std::env::set_var("QUALIA_APP_META_DIR", dir.path());
326 let plane = ensure_local_apparatus(Some(DeviceContext {
327 ownership: "owned_by_me".into(),
328 machine_fleet: "one_of_several".into(),
329 user_scope: "just_me".into(),
330 ..DeviceContext::default()
331 }))
332 .expect("apparatus");
333 assert!(plane.person.person_id.starts_with("did:q42:person:"));
334 assert!(plane.local_device_id.starts_with("did:q42:device:"));
335 assert_ne!(plane.person.person_id, plane.local_device_id);
336 assert!(plane.os_account_is_not_principal);
337 let os = std::env::var("USERNAME")
338 .or_else(|_| std::env::var("USER"))
339 .unwrap_or_default();
340 if !os.is_empty() {
341 assert!(!plane.person.person_id.contains(&os));
342 }
343 assert_eq!(plane.devices.len(), 1);
344 assert!(plane.devices[0].is_local);
345 assert_eq!(plane.devices[0].device_context.ownership, "owned_by_me");
346
347 let again = ensure_local_apparatus(None).unwrap();
349 assert_eq!(again.person.person_id, plane.person.person_id);
350 assert_eq!(again.local_device_id, plane.local_device_id);
351
352 let mut peer = plane.devices[0].clone();
354 peer.device_id =
355 "did:q42:device:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
356 .into();
357 peer.identity_pubkey_hex =
358 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".into();
359 peer.is_local = false;
360 peer.label = "laptop".into();
361 let with_peer = register_remote_device(peer).unwrap();
362 assert_eq!(with_peer.devices.len(), 2);
363 let place = resolve_job_placement(Some(
364 "did:q42:device:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
365 ))
366 .unwrap();
367 assert!(matches!(place, JobPlacement::RemoteRegistered { .. }));
368 std::env::remove_var("QUALIA_APP_META_DIR");
369 }
370}