Publish device facts over the peer plane

Paired devices answer a new DeviceFacts request with their hardware and
mounted volumes, under the ids their sources anchor to. The peer fetches
them on every sync beside the device summary and keeps them in the replica
manifest with its own receipt time, so an offline device still describes
itself as it last did.

volumes.list lists those volumes under their owner, mounted only while it's
connected, and devices.list fills in a paired device's hardware. The
overview folds replicas into their owner's volume bars the way local
sources fold into theirs. Another device's volumes show when they were last
seen and hide the local volume actions on desktop and mobile.

Volume classifications travel as names, so one a peer's build doesn't know
reads as unknown instead of failing the response.
This commit is contained in:
Jamie Pine committed 2026-09-18 18:08:47 -07:00
1 parent c94eaa0185
commit 4e58ddca55
12 files changed
+579 -65

No files matched your search

+1
View File
@@ -154,6 +154,7 @@ Recent landed.
| ------------------------------------------------- | -------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Source runtime reliability | R1-R3, R6 and R7 landed and verified live | in progress | Execute `docs/plans/2026-09-15-source-runtime-reliability.md`. Landed: destructive paths removed, flush made a durability barrier, self-nudge loop stopped and hashing progress labeled (`c63359a9e`, `adf729cd9`, `92bb4412e`); R1 ancestor synthesis (`3a425c629`); R2 anchored volume roots (`4b3eb7389`); R3 persisted replica inventory (`18304e041`) plus the fetch-generation fix (`0650e0acb`: header names the generation and blake3 of the bytes delivered, a delivery must parse before it replaces a good artifact, one failed source no longer aborts the device sync, fetch failures back off, an unchanged generation still refreshes owner facts, revocation unloads and quarantines replicas, snapshot decode is bounded); the coverage heal (`fd40a5b87`); watchers made unconditional on a bare daemon (`88f496891`, `1d8d23962`, `0e605105b`); dirty-driven refetches paced to five minutes (`b57cb2857`). R6 executed 2026-09-16, results in the plan: `51e4873d4` opened stores read-only with listings, lookups and Unicode-folded name search; `3d8c44394` routes one backend per source (arena when restored or walked, SQLite otherwise, no empty-result retry, `total_is_exact` on the wire, detached directories list from their stores); `475a6b0b3` delivers a nested source's replica as its own database with the snapshot header contract, verified live — dev-tools ships as an 11.3 MB database, the sibling leak is gone, and the replica lists and searches identically; the 100-store/1M-record cold fan-out measures 1.7 s (`b88bfe4cf`). R7 executed 2026-09-16, results in the plan: all ten stores validated by direct SQL with zero dangling ancestry, the home store needed no repair, cold-restart with titan down serves and searches all nine replicas, byte read, remote op and remote log stream verified, reconvergence transferred zero bytes for titan's nine sources. Titan runs the tags-era `sd-daemon` (deployed 2026-09-18); the `sd-server` relaunch stays with James and needs `DATA_DIR` plus `SD_AUTH`. Remaining: R4 compaction and memory honesty (Mac 3.1 GB / titan 3.4 GB RSS as its baseline), R5 typed health and coverage, R8 acceptance matrix and the docs sweep, plus R6's registered follow-ons (delta replication, request cancellation, the deferred title index). |
| Mac client operating titan | live acceptance reopened | unowned | Pairing, remote CLI operations, jobs and logs remain functional. The September 15 evening audit found only eight titan replicas active on the Mac: dev-tools remains registered on titan with 28,079 records, but its snapshot is unavailable and its arena/watcher are absent. Preserve the earlier NAS freezes. Recovery and renewed acceptance are specified in `docs/plans/2026-09-15-source-runtime-reliability.md`; the earlier nine-source proof is historical. |
| Peer device facts | committed; live proof pending | unowned | Paired devices publish their hardware and mounted volumes through the byterange `DeviceFacts` request. The Mac keeps titan's copy in its replica manifest, lists titan's volumes in `volumes.list`, fills titan's hardware into `devices.list`, and the overview folds each replica into the bar of the volume it lives on. Proof needs titan on this build, which runs the FD4 migration, so back up titan's library first. Then titan's card should show its volumes with capacity bars, replica file counts on the right bars, and CPU and RAM in the header; with titan's daemon stopped, its volumes should stay listed with a last-seen time. |
| Preserve NAS and pool evidence | pending | James | Save the TrueNAS config, encryption result, pool topology, GUID, and SMART output described by the runbook. |
| Track the Expansion drive | pending | James | Run one `sources track` pass over the Expansion drive before its contents merge into `jamie-nas`, then freeze it. |
| Back up and export | pending | James | Copy and verify `~/spacedrive/data` off titan (it now contains the freezes), then cleanly export the pool before the drives move. |
@@ -267,7 +267,12 @@ function DeviceCard({
<View className="px-3 py-3 gap-3">
{volumes.length > 0 ? (
volumes.map((volume, idx) => (
<VolumeBar key={volume.id} volume={volume} index={idx} />
<VolumeBar
key={volume.id}
volume={volume}
isLocal={device?.is_current ?? false}
index={idx}
/>
))
) : (
<View className="py-8 items-center justify-center">
@@ -483,6 +488,8 @@ function VolumeMenu({ volume, visible, onClose }: VolumeMenuProps) {
interface VolumeBarProps {
volume: Volume;
/** Whether the volume is on this device, where its actions run. */
isLocal: boolean;
index: number;
}
@@ -493,7 +500,7 @@ interface IndexingProgress {
rate: number;
}
function VolumeBar({ volume, index }: VolumeBarProps) {
function VolumeBar({ volume, isLocal, index }: VolumeBarProps) {
const [indexingProgress, setIndexingProgress] = useState<IndexingProgress | null>(null);
const [menuVisible, setMenuVisible] = useState(false);
const client = useSpacedriveClient();
@@ -639,11 +646,13 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
return (
<>
<VolumeMenu
volume={volume}
visible={menuVisible}
onClose={() => setMenuVisible(false)}
/>
{isLocal && (
<VolumeMenu
volume={volume}
visible={menuVisible}
onClose={() => setMenuVisible(false)}
/>
)}
<View className="bg-app-box border border-app-line/50 rounded-lg overflow-hidden">
{/* Top row: Info */}
@@ -711,12 +720,14 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
</Text>
</View>
<Pressable
onPress={() => setMenuVisible(true)}
className="px-1.5 py-1 active:bg-app-hover rounded self-start"
>
<Text className="text-ink-dull text-xl leading-none">⋮</Text>
</Pressable>
{isLocal && (
<Pressable
onPress={() => setMenuVisible(true)}
className="px-1.5 py-1 active:bg-app-hover rounded self-start"
>
<Text className="text-ink-dull text-xl leading-none">⋮</Text>
</Pressable>
)}
</View>
{/* Bottom: Capacity bar */}
+16 -11
View File
@@ -515,6 +515,21 @@ impl VolumeType {
!matches!(self, VolumeType::System | VolumeType::Unknown)
}
/// Parse the name `Display` writes; anything else is `Unknown`.
pub fn from_string(volume_type: &str) -> Self {
match volume_type {
"Primary" => VolumeType::Primary,
"UserData" => VolumeType::UserData,
"External" => VolumeType::External,
"Secondary" => VolumeType::Secondary,
"System" => VolumeType::System,
"Network" => VolumeType::Network,
"Cloud" => VolumeType::Cloud,
"Virtual" => VolumeType::Virtual,
_ => VolumeType::Unknown,
}
}
/// User-friendly display name for the volume type
pub fn display_name(&self) -> &'static str {
match self {
@@ -1001,17 +1016,7 @@ impl TrackedVolume {
.unwrap_or_else(|| "Not connected".to_string()),
),
mount_points: Vec::new(),
volume_type: match self.volume_type.as_str() {
"Primary" => VolumeType::Primary,
"UserData" => VolumeType::UserData,
"External" => VolumeType::External,
"Secondary" => VolumeType::Secondary,
"System" => VolumeType::System,
"Network" => VolumeType::Network,
"Cloud" => VolumeType::Cloud,
"Virtual" => VolumeType::Virtual,
_ => VolumeType::Unknown,
},
volume_type: VolumeType::from_string(&self.volume_type),
mount_type: MountType::External,
disk_type: DiskType::Unknown,
file_system: FileSystem::from_string(
+11 -1
View File
@@ -141,6 +141,10 @@ impl LibraryQuery for ListLibraryDevicesQuery {
// Always check network registry to update connection status for database devices
// and optionally add paired-only devices
if let Some(networking) = context.get_networking().await {
// Hardware each paired device published about itself, which the
// pairing handshake does not carry.
let published = crate::service::mounts::peer::published_facts().await;
let device_registry = networking.device_registry();
let registry = device_registry.read().await;
let all_devices = registry.get_all_devices();
@@ -203,6 +207,9 @@ impl LibraryQuery for ListLibraryDevicesQuery {
existing.is_connected = is_actually_connected;
existing.is_online = is_actually_connected;
existing.connection_method = connection_method;
if let Some(observed) = published.get(&device_id) {
observed.facts.hardware.apply_to(existing);
}
continue;
}
@@ -226,8 +233,11 @@ impl LibraryQuery for ListLibraryDevicesQuery {
}
// Convert network DeviceInfo to domain Device
let device =
let mut device =
Device::from_network_info(&info, is_actually_connected, connection_method);
if let Some(observed) = published.get(&device_id) {
observed.facts.hardware.apply_to(&mut device);
}
result.push(device);
}
}
+13
View File
@@ -167,6 +167,19 @@ impl LibraryQuery for VolumeListQuery {
}
}
// Paired devices' volumes, as each owner last published them. They are
// never candidates for tracking here. A volume already listed keeps
// its entry: this device's own observation of a drive outranks a
// peer's report of it.
if !matches!(self.filter, VolumeFilter::UntrackedOnly) {
for volume in crate::service::mounts::peer::published_volumes(&context).await {
let wanted = matches!(self.filter, VolumeFilter::All) || volume.is_tracked;
if wanted && !volumes.iter().any(|listed| listed.id == volume.id) {
volumes.push(volume);
}
}
}
// Unique bytes come from the source stores' distinct content sizes,
// rolled up onto the source rows as hashing lands. A whole-volume
// source is the volume's own figure; without one, subtree sources sum,
+175 -5
View File
@@ -12,8 +12,8 @@ use crate::ops::indexing::arena::Arena;
use crate::service::network::core::BYTERANGE_ALPN;
use crate::service::network::protocol::{
byterange::{
read_frame, write_frame, ByteRangeRequest, ByteRangeResponse, MAX_READ_LEN,
MAX_SNAPSHOT_LEN,
read_frame, write_frame, ByteRangeRequest, ByteRangeResponse, RemoteDeviceFacts,
MAX_READ_LEN, MAX_SNAPSHOT_LEN,
},
RemoteSourceInfo,
};
@@ -78,16 +78,28 @@ pub async fn open_replica_db(
}
}
/// A device's replica inventory as last synced, written beside the artifacts
/// it describes so the inventory survives a restart without the owner. A
/// A device's replica inventory and published facts as last synced, written
/// beside the artifacts so both survive a restart without the owner. A
/// replica has an owner and a validated generation even when no arena is
/// loaded; losing the list because the owner is unreachable would make a
/// drive's departure erase the knowledge that its copies exist.
/// drive's departure erase the knowledge that its copies exist. The facts
/// persist for the same reason: an offline device still owns its drives.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReplicaManifest {
pub device_id: Uuid,
pub device_label: String,
pub sources: Vec<ReplicaEntry>,
/// Absent until the owner has answered with facts.
#[serde(default)]
pub facts: Option<ObservedFacts>,
}
/// What a device published about itself, stamped with when this device
/// received it. The owner's clock never enters the stamp.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ObservedFacts {
pub facts: RemoteDeviceFacts,
pub observed_at_secs: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -129,6 +141,52 @@ pub async fn known_unloaded() -> Vec<(Uuid, String, ReplicaEntry)> {
.collect()
}
/// What every paired device last published about itself. Read from the
/// manifests, so a device that is offline still describes itself as it
/// last did.
pub async fn published_facts() -> HashMap<Uuid, ObservedFacts> {
known_map()
.read()
.await
.iter()
.filter_map(|(device_id, manifest)| Some((*device_id, manifest.facts.clone()?)))
.collect()
}
/// Paired devices' volumes as this device lists them.
pub async fn published_volumes(context: &Arc<CoreContext>) -> Vec<crate::domain::volume::Volume> {
let connected = match context.networking.read().await.clone() {
Some(networking) => super::connected_devices(&networking)
.await
.into_iter()
.map(|(device_id, _)| device_id)
.collect(),
None => std::collections::HashSet::new(),
};
volumes_from(&published_facts().await, &connected)
}
/// A volume reads as mounted only while its owner is connected. Otherwise
/// it stands as the owner last reported it, stamped with when that was.
fn volumes_from(
facts: &HashMap<Uuid, ObservedFacts>,
connected: &std::collections::HashSet<Uuid>,
) -> Vec<crate::domain::volume::Volume> {
facts
.iter()
.flat_map(|(device_id, observed)| {
let reachable = connected.contains(device_id);
let observed_at = chrono::DateTime::from_timestamp(observed.observed_at_secs as i64, 0)
.unwrap_or_default();
observed
.facts
.volumes
.iter()
.map(move |volume| volume.to_volume(*device_id, reachable, observed_at))
})
.collect()
}
fn manifest_path(replica_dir: &std::path::Path) -> PathBuf {
replica_dir.join("manifest.json")
}
@@ -873,6 +931,23 @@ pub async fn sync_device(
}
}
// What the owner publishes about itself refreshes on the same pass. An
// owner that does not answer leaves whatever an earlier pass recorded.
let facts = match request(context, device_id, &ByteRangeRequest::DeviceFacts).await {
Ok((ByteRangeResponse::DeviceFacts(facts), _)) => Some(ObservedFacts {
facts,
observed_at_secs: now_secs(),
}),
Ok((other, _)) => {
tracing::debug!("device facts from {device_label}: unexpected {other:?}");
None
}
Err(err) => {
tracing::debug!("device facts from {device_label} failed: {err}");
None
}
};
let replica_dir = context
.data_dir
.join("mounts-remote")
@@ -967,6 +1042,7 @@ pub async fn sync_device(
device_id,
device_label: device_label.clone(),
sources: entries,
facts: facts.or_else(|| previous.and_then(|manifest| manifest.facts)),
},
)
.await;
@@ -1055,6 +1131,7 @@ mod tests {
synced_at_secs: 1,
},
],
facts: None,
};
std::fs::write(
manifest_path(&replica_dir),
@@ -1261,6 +1338,7 @@ mod tests {
generation: 11,
synced_at_secs: 1,
}],
facts: None,
};
std::fs::write(
manifest_path(&replica_dir),
@@ -1371,4 +1449,96 @@ mod tests {
"identical facts rebuild nothing"
);
}
/// What an owner published persists with its manifest. After a restart
/// with the owner offline its volumes still list under it, unmounted and
/// stamped with when they were observed, and mount again on reconnect.
#[tokio::test]
async fn published_facts_outlive_the_owners_connection() {
use crate::service::network::protocol::byterange::{RemoteHardware, RemoteVolumeInfo};
let base = tempfile::tempdir().expect("dir");
let device_id = Uuid::now_v7();
let replica_dir = base.path().join(device_id.simple().to_string());
std::fs::create_dir_all(&replica_dir).expect("replica dir");
let volume_id = Uuid::now_v7();
let observed = ObservedFacts {
facts: RemoteDeviceFacts {
hardware: RemoteHardware {
cpu_model: Some("AMD EPYC 4464P".to_string()),
..Default::default()
},
volumes: vec![RemoteVolumeInfo {
id: volume_id,
fingerprint: "pool-fingerprint".to_string(),
name: "pool".to_string(),
display_name: Some("jamie-nas".to_string()),
mount_point: PathBuf::from("/mnt/pool/jamie-nas"),
volume_type: "External".to_string(),
disk_type: "HDD".to_string(),
file_system: "ZFS".to_string(),
total_capacity: 40 << 40,
available_space: 4 << 40,
is_read_only: false,
is_tracked: true,
read_speed_mbps: None,
write_speed_mbps: None,
}],
},
observed_at_secs: 1_789_700_000,
};
let manifest = ReplicaManifest {
device_id,
device_label: "titan".to_string(),
sources: Vec::new(),
facts: Some(observed.clone()),
};
std::fs::write(
manifest_path(&replica_dir),
serde_json::to_vec(&manifest).expect("serialize"),
)
.expect("manifest");
restore_from(base.path()).await;
let published = published_facts().await;
assert_eq!(published.get(&device_id), Some(&observed));
let offline = volumes_from(&published, &std::collections::HashSet::new());
let pool = offline
.iter()
.find(|volume| volume.id == volume_id)
.expect("the volume lists while its owner is away");
assert_eq!(pool.device_id, device_id);
assert!(!pool.is_mounted);
assert_eq!(pool.last_seen_at.timestamp(), 1_789_700_000);
let online = volumes_from(&published, &std::collections::HashSet::from([device_id]));
assert!(
online
.iter()
.any(|volume| volume.id == volume_id && volume.is_mounted),
"a connected owner's volume reads as mounted"
);
}
/// A manifest without facts restores; the device has none until its
/// owner answers.
#[tokio::test]
async fn a_manifest_without_facts_still_restores() {
let base = tempfile::tempdir().expect("dir");
let device_id = Uuid::now_v7();
let replica_dir = base.path().join(device_id.simple().to_string());
std::fs::create_dir_all(&replica_dir).expect("replica dir");
std::fs::write(
manifest_path(&replica_dir),
serde_json::json!({ "device_id": device_id, "device_label": "titan", "sources": [] })
.to_string(),
)
.expect("manifest");
restore_from(base.path()).await;
assert!(known_map().read().await.contains_key(&device_id));
assert!(!published_facts().await.contains_key(&device_id));
}
}
@@ -11,8 +11,12 @@
//! same stream so payloads are never re-encoded through msgpack.
use crate::context::CoreContext;
use crate::device::DeviceConfig;
use crate::domain::device::{parse_device_form_factor_from_string, Device};
use crate::domain::volume::{DiskType, FileSystem, Volume, VolumeFingerprint, VolumeType};
use crate::service::network::device::registry::DeviceRegistry;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
@@ -57,6 +61,9 @@ pub enum ByteRangeRequest {
/// whose replica must carry exactly the source's own records. Appended
/// after the original variants so their wire indices hold.
FetchDatabase { source_id: Uuid },
/// What the serving device publishes about itself: its hardware and the
/// volumes it has mounted.
DeviceFacts,
}
/// One device's own accounting of what it holds, computed by the same
@@ -72,6 +79,145 @@ pub struct RemoteDeviceSummary {
pub available_capacity: u64,
}
/// What a device publishes about itself. Only that device writes these
/// facts, so a peer keeps the latest copy it received and never merges it
/// into records of its own.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteDeviceFacts {
pub hardware: RemoteHardware,
/// Volumes the device has mounted and shows a person.
pub volumes: Vec<RemoteVolumeInfo>,
}
impl RemoteDeviceFacts {
/// Describe this device from its configuration and its volume manager's
/// live volumes. Hidden and unmounted volumes stay out, as they do from
/// the device's own volume list.
pub fn describe(config: &DeviceConfig, volumes: &[Volume]) -> Self {
Self {
hardware: RemoteHardware {
hardware_model: config.hardware_model.clone(),
cpu_model: config.cpu_model.clone(),
cpu_cores_physical: config.cpu_cores_physical,
cpu_cores_logical: config.cpu_cores_logical,
memory_total_bytes: config.memory_total_bytes,
form_factor: config.form_factor.clone(),
manufacturer: config.manufacturer.clone(),
},
volumes: volumes
.iter()
.filter(|volume| volume.is_user_visible && volume.is_mounted)
.map(RemoteVolumeInfo::from)
.collect(),
}
}
}
/// The hardware a device's own detection recorded, limited to what clients
/// read to describe a device.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RemoteHardware {
pub hardware_model: Option<String>,
pub cpu_model: Option<String>,
pub cpu_cores_physical: Option<u32>,
pub cpu_cores_logical: Option<u32>,
pub memory_total_bytes: Option<i64>,
pub form_factor: Option<String>,
pub manufacturer: Option<String>,
}
impl RemoteHardware {
/// Describe `device` with what its owner published, keeping any field
/// the owner left unreported.
pub fn apply_to(&self, device: &mut Device) {
device.hardware_model = self.hardware_model.clone().or(device.hardware_model.take());
device.cpu_model = self.cpu_model.clone().or(device.cpu_model.take());
device.cpu_cores_physical = self.cpu_cores_physical.or(device.cpu_cores_physical);
device.cpu_cores_logical = self.cpu_cores_logical.or(device.cpu_cores_logical);
device.memory_total_bytes = self.memory_total_bytes.or(device.memory_total_bytes);
device.form_factor = self
.form_factor
.as_deref()
.map(parse_device_form_factor_from_string)
.or(device.form_factor);
device.manufacturer = self.manufacturer.clone().or(device.manufacturer.take());
}
}
/// One volume as the device that mounts it sees it. `id` is the id that
/// device's sources anchor to, which is how a replica finds the drive it
/// lives on. Classifications travel as names and are parsed on receipt, so
/// one a peer's build does not know reads as unknown instead of failing the
/// whole response.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteVolumeInfo {
pub id: Uuid,
pub fingerprint: String,
pub name: String,
pub display_name: Option<String>,
pub mount_point: PathBuf,
pub volume_type: String,
pub disk_type: String,
pub file_system: String,
pub total_capacity: u64,
pub available_space: u64,
pub is_read_only: bool,
pub is_tracked: bool,
pub read_speed_mbps: Option<u64>,
pub write_speed_mbps: Option<u64>,
}
impl From<&Volume> for RemoteVolumeInfo {
fn from(volume: &Volume) -> Self {
Self {
id: volume.id,
fingerprint: volume.fingerprint.0.clone(),
name: volume.name.clone(),
display_name: volume.display_name.clone(),
mount_point: volume.mount_point.clone(),
volume_type: volume.volume_type.to_string(),
disk_type: volume.disk_type.to_string(),
file_system: volume.file_system.to_string(),
total_capacity: volume.total_capacity,
available_space: volume.available_space,
is_read_only: volume.is_read_only,
is_tracked: volume.is_tracked,
read_speed_mbps: volume.read_speed_mbps,
write_speed_mbps: volume.write_speed_mbps,
}
}
}
impl RemoteVolumeInfo {
/// The volume as this device lists it: owned by `owner`, mounted only
/// while the owner is reachable, and last seen when its facts arrived.
pub fn to_volume(&self, owner: Uuid, reachable: bool, observed_at: DateTime<Utc>) -> Volume {
let mut volume = Volume::new(
owner,
VolumeFingerprint(self.fingerprint.clone()),
self.name.clone(),
self.mount_point.clone(),
);
volume.id = self.id;
volume.display_name = self.display_name.clone();
volume.volume_type = VolumeType::from_string(&self.volume_type);
volume.disk_type = DiskType::from_string(&self.disk_type);
volume.file_system = FileSystem::from_string(&self.file_system);
volume.total_capacity = self.total_capacity;
volume.available_space = self.available_space;
volume.is_read_only = self.is_read_only;
volume.is_tracked = self.is_tracked;
volume.is_mounted = reachable;
volume.auto_track_eligible = false;
volume.read_speed_mbps = self.read_speed_mbps;
volume.write_speed_mbps = self.write_speed_mbps;
volume.created_at = observed_at;
volume.updated_at = observed_at;
volume.last_seen_at = observed_at;
volume
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteSourceInfo {
pub id: Uuid,
@@ -135,6 +281,7 @@ pub enum ByteRangeResponse {
generation: u64,
checksum: [u8; 32],
},
DeviceFacts(RemoteDeviceFacts),
}
pub async fn write_frame<W, T>(stream: &mut W, msg: &T) -> anyhow::Result<()>
@@ -433,6 +580,24 @@ impl ByteRangeProtocolHandler {
let _ = tokio::fs::remove_file(&export_path).await;
served
}
ByteRangeRequest::DeviceFacts => {
let config = match self.context.device_manager.config() {
Ok(config) => config,
Err(err) => {
return write_frame(
send,
&ByteRangeResponse::Error(format!("device config unavailable: {err}")),
)
.await;
}
};
let volumes = self.context.volume_manager.get_all_volumes().await;
write_frame(
send,
&ByteRangeResponse::DeviceFacts(RemoteDeviceFacts::describe(&config, &volumes)),
)
.await
}
}
}
}
@@ -652,4 +817,101 @@ mod tests {
other => panic!("decoded to the wrong variant: {other:?}"),
}
}
fn volume(name: &str, visible: bool, mounted: bool) -> Volume {
let mut volume = Volume::new(
Uuid::now_v7(),
VolumeFingerprint(format!("fingerprint-{name}")),
name.to_string(),
PathBuf::from(format!("/mnt/{name}")),
);
volume.is_user_visible = visible;
volume.is_mounted = mounted;
volume
}
/// A device describes the volumes it shows a person, under the ids its
/// sources anchor to, with its hardware alongside.
#[test]
fn facts_describe_what_the_device_shows() {
let mut config = DeviceConfig::new("titan".to_string(), "Linux".to_string());
config.cpu_model = Some("AMD EPYC 4464P".to_string());
config.memory_total_bytes = Some(128 << 30);
let shown = volume("pool", true, true);
let facts = RemoteDeviceFacts::describe(
&config,
&[
shown.clone(),
volume("boot", false, true),
volume("usb", true, false),
],
);
assert_eq!(
facts.volumes.iter().map(|v| v.id).collect::<Vec<_>>(),
vec![shown.id],
"hidden and unmounted volumes stay out"
);
assert_eq!(facts.hardware.cpu_model.as_deref(), Some("AMD EPYC 4464P"));
assert_eq!(facts.hardware.memory_total_bytes, Some(128 << 30));
}
/// A published volume lists on a peer as its owner's, classified the way
/// the owner classified it, and mounted only while the owner is reachable.
#[test]
fn a_published_volume_lists_as_its_owners() {
let mut original = volume("pool", true, true);
original.display_name = Some("jamie-nas".to_string());
original.volume_type = VolumeType::External;
original.disk_type = DiskType::HDD;
original.file_system = FileSystem::ZFS;
original.total_capacity = 40 << 40;
original.available_space = 4 << 40;
let owner = Uuid::now_v7();
let observed_at = DateTime::from_timestamp(1_789_700_000, 0).expect("time");
let listed = RemoteVolumeInfo::from(&original).to_volume(owner, false, observed_at);
assert_eq!(listed.id, original.id, "the anchor id survives the trip");
assert_eq!(listed.device_id, owner);
assert_eq!(listed.display_name.as_deref(), Some("jamie-nas"));
assert_eq!(listed.volume_type, VolumeType::External);
assert_eq!(listed.disk_type, DiskType::HDD);
assert_eq!(listed.file_system, FileSystem::ZFS);
assert_eq!(
(listed.total_capacity, listed.available_space),
(40 << 40, 4 << 40)
);
assert!(
!listed.is_mounted,
"an unreachable owner's volume is not mounted here"
);
assert_eq!(listed.last_seen_at, observed_at);
}
/// Facts cross the frame codec intact, and a classification this build
/// does not know arrives as unknown instead of failing the response.
#[tokio::test]
async fn device_facts_survive_the_frame_codec() {
let mut info = RemoteVolumeInfo::from(&volume("pool", true, true));
info.volume_type = "Holographic".to_string();
let facts = RemoteDeviceFacts {
hardware: RemoteHardware::default(),
volumes: vec![info],
};
let mut wire = Vec::new();
write_frame(&mut wire, &ByteRangeResponse::DeviceFacts(facts.clone()))
.await
.expect("encode");
match read_frame(&mut wire.as_slice()).await.expect("decode") {
ByteRangeResponse::DeviceFacts(received) => {
assert_eq!(received, facts);
let listed = received.volumes[0].to_volume(Uuid::now_v7(), true, Utc::now());
assert_eq!(listed.volume_type, VolumeType::Unknown);
}
other => panic!("decoded to the wrong variant: {other:?}"),
}
}
}
+21
View File
@@ -449,6 +449,27 @@ not have. Worth measuring the staleness before forking a crate over it.
**Not built yet**: drop takeover (phase 3), the native FSKit module
(phase 4), pinning (phase 5), renditions (phase 6), writes (phase 7).
## Implementation status (2026-09-18)
**Device facts.** `DeviceFacts` is a byterange request answered with what
the serving device publishes about itself: the hardware its startup
detection recorded, and every mounted, user-visible volume under the id its
sources anchor to. A peer fetches it on every sync pass beside
`DeviceSummary`, stamps it with its own receipt time, and keeps it in the
device's replica manifest, so an offline device still describes itself as it
last did. `volumes.list` lists a paired device's volumes from those facts,
mounted only while the owner is connected, and `devices.list` fills in a
paired device's hardware from them. The overview folds each replica into its
owner's volume bar by `volume_uuid`, the same way a local source folds into
its own.
Only the owner writes its facts, so nothing merges and library sync is not
involved. Volume classifications travel as names, so one a peer's build does
not know reads as unknown instead of failing the response. The facts cross
library boundaries the way replicas and device summaries already do; scoping
them belongs to library membership. An owner that cannot answer the request
leaves its card listing replicas as rows.
## Open questions
- One OS volume per mount target, or one Spacedrive volume with targets as
+1
View File
@@ -57,6 +57,7 @@ See [Device Pairing](/core/pairing) for the protocol.
An authorized device can expose:
- its hardware and mounted volumes;
- source queries and search;
- snapshots and catalog projections;
- byte ranges and previews;
+8
View File
@@ -31,6 +31,14 @@ Each daemon observes mounted local and network filesystems. It records:
The observation updates current reachability. It does not overwrite historical
evidence about the hardware.
## Volumes on paired devices
A paired device publishes the volumes it has mounted, along with its hardware.
Peers keep the latest copy with the time they received it and list those
volumes under the device that owns them. While the owner is offline its volumes
still appear, unmounted, as they were last seen. A replica of one of the
owner's sources appears on the volume its anchor names.
## The volume arena
Mounted filesystems enter a machine-level in-memory arena. The arena provides a
@@ -163,28 +163,28 @@ export function DevicePanel() {
{} as Record<string, Volume[]>
);
// A local source on a listed volume is part of that volume's bar. Any
// other filesystem source gets its own row on the owning device's card: a
// paired device's replica, which carries its owner's id, or a local source
// on a volume the card doesn't list. Adapter sources live on no device.
// A source is part of its volume's bar when that volume is listed under
// the device that owns the source. A replica carries its owner's id; a
// local source carries none. Any other filesystem source gets its own row
// on its owner's card. Adapter sources live on no device.
const currentDeviceId = devices.find((device) => device.is_current)?.id;
const listedVolumeIds = new Set(
userVisibleVolumes.map((volume) => volume.id)
const volumeOwners = new Map(
userVisibleVolumes.map((volume) => [volume.id, volume.device_id])
);
const sourcesByVolume: Record<string, SourceInfo[]> = {};
const sourceRowsByDevice: Record<string, SourceInfo[]> = {};
for (const source of sourcesData ?? []) {
if (source.data_type !== 'filesystem') continue;
const owner = source.device_id ?? currentDeviceId;
if (!owner) continue;
if (
!source.device_id &&
source.volume_uuid &&
listedVolumeIds.has(source.volume_uuid)
volumeOwners.get(source.volume_uuid) === owner
) {
(sourcesByVolume[source.volume_uuid] ??= []).push(source);
continue;
} else {
(sourceRowsByDevice[owner] ??= []).push(source);
}
const owner = source.device_id ?? currentDeviceId;
if (owner) (sourceRowsByDevice[owner] ??= []).push(source);
}
// Group jobs by device_id
@@ -479,6 +479,7 @@ function DeviceCard({
key={volume.id}
volume={volume}
sources={sourcesByVolume[volume.id] || []}
isLocal={device?.is_current ?? false}
index={idx}
/>
))
@@ -499,10 +500,8 @@ function DeviceCard({
);
}
// A source no volume bar on the card covers: a paired device's source
// replicated through the peer-mount plane, or a local source on an unlisted
// volume. There is no capacity bar because the row knows the source's
// contents, not the drive underneath it.
// A source whose volume the card doesn't list. There is no capacity bar
// because the row knows the source's contents, not the drive underneath it.
function SourceRow({source}: {source: SourceInfo}) {
return (
<div className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border">
@@ -6,6 +6,7 @@ import {useEffect, useState} from 'react';
import {useVolumeContextMenu} from '../../components/SpacesSidebar/hooks/useVolumeContextMenu';
import {useSpacedriveClient} from '../../contexts/SpacedriveContext';
import {useVolumeIndexingStore} from '../../stores/volumeIndexingStore';
import {formatRelativeTime} from '../explorer/utils';
import {formatBytes, getVolumeIcon} from './DevicePanel';
function getDiskTypeLabel(diskType: string): string {
@@ -16,6 +17,8 @@ interface VolumeBarProps {
volume: Volume;
/** The library's sources on this volume. */
sources: SourceInfo[];
/** Whether the volume is on this device, where its actions run. */
isLocal: boolean;
index: number;
}
@@ -26,7 +29,7 @@ interface IndexingProgress {
rate: number;
}
export function VolumeBar({volume, sources, index}: VolumeBarProps) {
export function VolumeBar({volume, sources, isLocal, index}: VolumeBarProps) {
const [indexingProgress, setIndexingProgress] =
useState<IndexingProgress | null>(null);
const client = useSpacedriveClient();
@@ -194,7 +197,7 @@ export function VolumeBar({volume, sources, index}: VolumeBarProps) {
animate={{opacity: 1, y: 0}}
transition={{delay: index * 0.05}}
className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border"
onContextMenu={contextMenu.show}
onContextMenu={isLocal ? contextMenu.show : undefined}
>
{/* Top row: Info - fixed height */}
<div className="flex h-[64px] items-center gap-3 px-3">
@@ -266,30 +269,40 @@ export function VolumeBar({volume, sources, index}: VolumeBarProps) {
{formatBytes(availableBytes)} free
</div>
<div className="text-ink-faint flex h-3.5 items-center justify-end gap-1.5 text-[10px]">
{volume.read_speed_mbps && (
<span className="flex items-center gap-0.5">
<ArrowDown size={10} weight="bold" />
{volume.read_speed_mbps}MB/s
</span>
)}
{volume.write_speed_mbps && (
<span className="flex items-center gap-0.5">
<ArrowUp size={10} weight="bold" />
{volume.write_speed_mbps}MB/s
{!volume.is_mounted ? (
<span>
seen {formatRelativeTime(volume.last_seen_at)}
</span>
) : (
<>
{volume.read_speed_mbps && (
<span className="flex items-center gap-0.5">
<ArrowDown size={10} weight="bold" />
{volume.read_speed_mbps}MB/s
</span>
)}
{volume.write_speed_mbps && (
<span className="flex items-center gap-0.5">
<ArrowUp size={10} weight="bold" />
{volume.write_speed_mbps}MB/s
</span>
)}
</>
)}
</div>
</div>
{/* Three dots button - far right */}
<CircleButton
icon={DotsThree}
onClick={(e) => {
e.stopPropagation();
contextMenu.show(e);
}}
title="Volume actions"
/>
{isLocal && (
<CircleButton
icon={DotsThree}
onClick={(e) => {
e.stopPropagation();
contextMenu.show(e);
}}
title="Volume actions"
/>
)}
</div>
{/* Bottom: Full-width capacity bar with padding */}