Skip to main content

qualia_client_core/identity_plane/
fleet.rs

1//! Local fleet registry: one person, many apparatus installs.
2
3use 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/// UI/API snapshot: person public half + devices (no secrets).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct IdentityPlaneSnapshot {
23    pub person: PersonPublic,
24    pub local_device_id: String,
25    pub devices: Vec<DeviceRecordPublic>,
26    /// Explicit: OS login is not used as a Qualia principal.
27    pub os_account_is_not_principal: bool,
28    pub notes: Vec<String>,
29}
30
31/// Where a job should run relative to this process.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case", tag = "placement")]
34pub enum JobPlacement {
35    /// Run on this process / local job queue.
36    Local { device_id: String },
37    /// Target is a registered peer device — dispatch not yet live (fail-closed).
38    RemoteRegistered { device_id: String, label: String },
39    /// Unknown device id.
40    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
71/// Ensure person + local apparatus + fleet entry exist.
72///
73/// Safe to call at startup and after setup. Does not use OS username as identity.
74pub 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    // Prefer explicit LAN URL already stored; else stamp loopback control port when known.
105    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            // Upsert local record
123            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                    // Preserve a user-set non-loopback URL if present.
137                    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                // Mark any previous local flags false (reinstall edge case)
148                for d in f.devices.iter_mut() {
149                    d.is_local = false;
150                }
151                f.devices.push(local.clone());
152            }
153            // Keep person_id consistent on all devices we own in this fleet file
154            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
207/// Full person secret for multi-machine install. Caller must treat as recovery material.
208pub fn export_person_transfer_bundle() -> Result<PersonTransferBundle, String> {
209    Ok(PersonPrincipal::load_or_create(None)?.transfer_bundle())
210}
211
212/// Import person principal onto this machine, then re-bind local apparatus under them.
213pub fn import_person_transfer_bundle(
214    bundle: PersonTransferBundle,
215) -> Result<IdentityPlaneSnapshot, String> {
216    PersonPrincipal::from_transfer_bundle(bundle)?;
217    // Rebuild fleet local entry under the imported person.
218    ensure_local_apparatus(None)
219}
220
221/// Register another apparatus (peer / second PC) for fleet awareness and job targeting.
222pub 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
252/// Update this apparatus' advertised control URL (LAN/VPN base for fleet jobs).
253pub 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    // app_meta_dir is process-global via env; serialise tests that mutate it.
305    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        // Second ensure is stable.
348        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        // Remote peer under same person.
353        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}