.tasks/core/VSS-004-cross-device-sync.md
Implement cross-device sidecar discovery and transfer, enabling devices to share and reuse generated sidecars instead of regenerating them locally. This is critical for "generate once, use everywhere" efficiency.
Discovery Model: Devices periodically gossip availability digests over the network. When looking for a sidecar, devices query connected peers directly (not via database sync). The sidecar_availability table tracks only what the current device has locally.
See workbench/core/storage/VIRTUAL_SIDECAR_SYSTEM_V2.md Section "Cross-Device Sync" for complete specification.
core/src/service/sync/sidecar_sync.rs - New file for availability synccore/src/service/network/protocol/sidecar_transfer.rs - Transfer protocolcore/src/service/sidecar_manager.rs - Add remote availability methodsAvailabilityDigest structureSidecarTransferJobSdPathResolver to check remote availability// Desktop: User indexes photos
// → ThumbnailJob generates grid@2x thumbnails
// → Records in database: status=ready
// → Updates availability: desktop_device has grid@2x
// Sync happens (periodic, every 5 minutes)
// → Desktop sends availability digest to mobile
// → Mobile updates sidecar_availability table
// Mobile: User opens photo grid
// → Requests sidecar://550e8400.../thumbs/[email protected]
// → Resolver checks local: missing
// → Resolver checks availability: desktop has it
// → Resolver returns Remote(desktop_device, path)
// → UI triggers fetch from desktop
// → Thumbnail transferred via P2P
// → Mobile updates: status=ready, has=true
// Content exists on: MacBook (WiFi), Home Server (ethernet), Cloud (internet)
// All have the thumbnail
// Resolver evaluates sources:
// - MacBook: 45 MB/s, latency 2ms (local WiFi)
// - Home Server: 110 MB/s, latency 1ms (local ethernet)
// - Cloud: 10 MB/s, latency 50ms (internet)
// Selects Home Server (fastest + lowest latency)
// Fetches thumbnail in ~0.5ms
pub struct AvailabilityDigest {
/// Device that owns this digest
pub device_id: Uuid,
/// Timestamp of digest creation
pub timestamp: DateTime<Utc>,
/// Compact representation of available sidecars
/// For large sets, could use bloom filter
pub sidecars: Vec<SidecarAvailabilityEntry>,
}
pub struct SidecarAvailabilityEntry {
pub content_uuid: Uuid,
pub kind: SidecarKind,
pub variant: SidecarVariant,
pub size: u64,
pub checksum: Option<String>,
}
#[derive(Job)]
pub struct SidecarTransferJob {
pub content_uuid: Uuid,
pub kind: SidecarKind,
pub variant: SidecarVariant,
pub format: SidecarFormat,
pub source_device: Uuid,
}
impl SidecarTransferJob {
async fn execute(&self, ctx: JobContext) -> Result<()> {
// 1. Request sidecar from source device
let sidecar_path = SdPath::sidecar(
self.content_uuid,
self.kind,
self.variant,
self.format,
);
let source_physical = ctx.resolver.resolve_on_device(
sidecar_path,
self.source_device,
).await?;
// 2. Compute local destination
let dest_path = ctx.sidecar_manager.compute_path(...)?;
// 3. Transfer file via P2P
ctx.file_transfer.execute(
source_physical,
SdPath::Physical {
device_slug: ctx.current_device_slug(),
path: dest_path.absolute_path,
},
TransferMode::VerifyChecksum,
).await?;
// 4. Record locally
let size = fs::metadata(&dest_path.absolute_path).await?.len();
ctx.sidecar_manager.record_sidecar(..., size, checksum).await?;
Ok(())
}
}
pub struct SidecarPrefetchPolicy {
/// Always prefetch these kinds
pub eager_kinds: HashSet<SidecarKind>,
/// Prefetch these only when requested
pub lazy_kinds: HashSet<SidecarKind>,
/// Never prefetch (too large)
pub never_prefetch: HashSet<SidecarKind>,
/// Maximum concurrent prefetch jobs
pub max_concurrent: usize,
}
impl Default for SidecarPrefetchPolicy {
fn default() -> Self {
Self {
eager_kinds: hashset![SidecarKind::Thumb],
lazy_kinds: hashset![SidecarKind::Ocr, SidecarKind::Transcript],
never_prefetch: hashset![SidecarKind::Proxy],
max_concurrent: 3,
}
}
}
Estimated: 1 week focused work
#[tokio::test]
async fn test_cross_device_sidecar_availability() {
let (alice, bob) = create_paired_devices().await;
// Alice generates thumbnail
let content_uuid = index_image_on_alice().await;
generate_thumbnail(alice, content_uuid, "grid@2x").await;
// Sync availability
exchange_availability_digests(alice, bob).await;
// Bob should know Alice has the thumbnail
let availability = bob.sidecar_manager.get_presence(
&bob.library,
&[content_uuid],
&SidecarKind::Thumb,
&["grid@2x"],
).await?;
assert!(availability[&content_uuid]["grid@2x"].devices.contains(&alice.device_id));
}
#[tokio::test]
async fn test_cross_device_sidecar_fetch() {
let (alice, bob) = create_paired_devices().await;
// Alice has thumbnail, Bob doesn't
let content_uuid = setup_thumbnail_on_alice(alice).await;
sync_availability(alice, bob).await;
// Bob requests thumbnail
let sidecar = SdPath::sidecar(content_uuid, Thumb, "grid@2x", Webp);
let resolved = bob.resolver.resolve(sidecar).await?;
// Should resolve to Alice
assert!(matches!(resolved, ResolvedPath::Remote { device_id, .. } if device_id == alice.id));
// Fetch it
let bytes = bob.fetch_sidecar(resolved).await?;
// Verify thumbnail
assert_eq!(bytes.len(), 45_000); // ~45KB thumbnail
assert!(is_valid_webp(&bytes));
// Bob should now have it locally
let resolved2 = bob.resolver.resolve(sidecar).await?;
assert!(matches!(resolved2, ResolvedPath::Local { .. }));
}