add magic-wormhole sync command

Amolith created

Change summary

Cargo.lock        | 806 ++++++++++++++++++++++++++++++++++++++++++++++++
Cargo.toml        |   2 
src/cli.rs        |   6 
src/cmd/mod.rs    |   5 
src/cmd/sync.rs   | 230 +++++++++++++
src/db.rs         |  11 
tests/cli_sync.rs | 178 ++++++++++
7 files changed, 1,235 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2,6 +2,41 @@
 # It is not intended for manual editing.
 version = 4
 
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
 [[package]]
 name = "ahash"
 version = "0.8.12"
@@ -69,7 +104,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -80,7 +115,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -131,6 +166,206 @@ dependencies = [
  "wait-timeout",
 ]
 
+[[package]]
+name = "async-attributes"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
+dependencies = [
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener 2.5.3",
+ "futures-core",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-global-executor"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "blocking",
+ "futures-lite",
+ "once_cell",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener 5.4.1",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener 5.4.1",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-std"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
+dependencies = [
+ "async-attributes",
+ "async-channel 1.9.0",
+ "async-global-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "crossbeam-utils",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-lite",
+ "gloo-timers",
+ "kv-log-macro",
+ "log",
+ "memchr",
+ "once_cell",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-tungstenite"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef0f7efedeac57d9b26170f72965ecfd31473ca52ca7a64e925b0b6f5f079886"
+dependencies = [
+ "async-std",
+ "atomic-waker",
+ "futures-core",
+ "futures-io",
+ "futures-task",
+ "futures-util",
+ "log",
+ "pin-project-lite",
+ "tungstenite",
+]
+
+[[package]]
+name = "async_io_stream"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
+dependencies = [
+ "futures",
+ "pharos",
+ "rustc_version",
+]
+
 [[package]]
 name = "atomic-polyfill"
 version = "1.0.3"
@@ -140,12 +375,24 @@ dependencies = [
  "critical-section",
 ]
 
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
 [[package]]
 name = "autocfg"
 version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
 
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
 [[package]]
 name = "bitflags"
 version = "2.11.0"
@@ -161,6 +408,15 @@ dependencies = [
  "typenum",
 ]
 
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
 [[package]]
 name = "block-buffer"
 version = "0.10.4"
@@ -170,6 +426,19 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
 [[package]]
 name = "bstr"
 version = "1.12.1"
@@ -187,6 +456,16 @@ version = "3.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
 
+[[package]]
+name = "bytecodec"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adf4c9d0bbf32eea58d7c0f812058138ee8edaf0f2802b6d03561b504729a325"
+dependencies = [
+ "byteorder",
+ "trackable 0.2.24",
+]
+
 [[package]]
 name = "byteorder"
 version = "1.5.0"
@@ -215,6 +494,30 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
 
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
 [[package]]
 name = "chrono"
 version = "0.4.44"
@@ -226,6 +529,17 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
 [[package]]
 name = "clap"
 version = "4.5.60"
@@ -292,6 +606,15 @@ dependencies = [
  "unicode-width",
 ]
 
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -307,12 +630,33 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+
 [[package]]
 name = "critical-section"
 version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
 
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
 [[package]]
 name = "crossterm"
 version = "0.29.0"
@@ -343,9 +687,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
 dependencies = [
  "generic-array",
+ "rand_core 0.6.4",
  "typenum",
 ]
 
+[[package]]
+name = "crypto_secretbox"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
+dependencies = [
+ "aead",
+ "cipher",
+ "generic-array",
+ "poly1305",
+ "salsa20",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "fiat-crypto",
+ "rand_core 0.6.4",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "darling"
 version = "0.20.11"
@@ -381,6 +777,21 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "data-encoding"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
 [[package]]
 name = "derive_arbitrary"
 version = "1.4.2"
@@ -392,6 +803,27 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "unicode-xid",
+]
+
 [[package]]
 name = "diff"
 version = "0.1.13"
@@ -412,6 +844,18 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
 dependencies = [
  "block-buffer",
  "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -496,7 +940,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener 5.4.1",
+ "pin-project-lite",
 ]
 
 [[package]]
@@ -505,6 +976,23 @@ version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
 
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "filetime"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+]
+
 [[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
@@ -532,6 +1020,116 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
 [[package]]
 name = "generator"
 version = "0.8.8"
@@ -555,6 +1153,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
 dependencies = [
  "typenum",
  "version_check",
+ "zeroize",
 ]
 
 [[package]]
@@ -609,6 +1208,28 @@ dependencies = [
  "wasip3",
 ]
 
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
+[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "hash32"
 version = "0.2.1"
@@ -688,6 +1309,55 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
 [[package]]
 name = "iana-time-zone"
 version = "0.1.65"
@@ -712,6 +1382,87 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
 [[package]]
 name = "id-arena"
 version = "2.3.0"
@@ -724,6 +1475,37 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
 
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "if-addrs"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "im"
 version = "15.1.0"
@@ -751,6 +1533,15 @@ dependencies = [
  "serde_core",
 ]
 
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.2"
@@ -791,6 +1582,15 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "kv-log-macro"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
+dependencies = [
+ "log",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.5.0"

Cargo.toml 🔗

@@ -17,6 +17,8 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 loro = "1"
 ulid = "1"
+magic-wormhole = "0.7.6"
+tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
 
 [dev-dependencies]
 assert_cmd = "2"

src/cli.rs 🔗

@@ -204,6 +204,12 @@ pub enum Command {
         file: String,
     },
 
+    /// Sync project state with a peer via magic wormhole
+    Sync {
+        /// Wormhole code to connect to a peer (omit to generate one)
+        code: Option<String>,
+    },
+
     /// Install the agent skill file (SKILL.md)
     Skill {
         /// Skills directory (writes managing-tasks-with-td/SKILL.md inside)

src/cmd/mod.rs 🔗

@@ -17,6 +17,7 @@ mod search;
 mod show;
 mod skill;
 mod stats;
+pub mod sync;
 mod update;
 mod r#use;
 
@@ -170,6 +171,10 @@ pub fn dispatch(cli: &Cli) -> Result<()> {
             let root = require_root()?;
             import::run(&root, file)
         }
+        Command::Sync { code } => {
+            let root = require_root()?;
+            sync::run(&root, code.as_deref(), cli.json)
+        }
         Command::Skill { dir } => skill::run(dir.as_deref()),
     }
 }

src/cmd/sync.rs 🔗

@@ -0,0 +1,230 @@
+//! Peer-to-peer project sync via magic-wormhole.
+//!
+//! Both peers open the same project, exchange Loro version vectors,
+//! compute deltas containing only the ops the other side lacks, then
+//! exchange and import those deltas.  The result is that both docs
+//! converge to the same state without sending duplicate operations.
+
+use std::borrow::Cow;
+use std::path::Path;
+
+use anyhow::{bail, Context, Result};
+use loro::{ExportMode, VersionVector};
+use magic_wormhole::{AppConfig, AppID, Code, MailboxConnection, Wormhole};
+use serde::{Deserialize, Serialize};
+
+use crate::db;
+
+/// Custom AppID scoping our wormhole traffic away from other protocols.
+const APP_ID: &str = "td.sync.v1";
+
+/// Number of random words in the generated wormhole code.
+const CODE_WORD_COUNT: usize = 2;
+
+/// Handshake message exchanged before the delta payload.
+#[derive(Debug, Serialize, Deserialize)]
+struct SyncHandshake {
+    /// Human-readable project name.
+    project_name: String,
+    /// Stable identity (ULID stored in the doc's root meta map).
+    project_id: String,
+    /// Serialised version vector so the peer can compute a minimal delta.
+    #[serde(with = "vv_serde")]
+    version_vector: VersionVector,
+}
+
+/// Serde adapter for `VersionVector` using its postcard `encode()`/`decode()`.
+mod vv_serde {
+    use loro::VersionVector;
+    use serde::{self, Deserializer, Serializer};
+
+    pub fn serialize<S: Serializer>(vv: &VersionVector, ser: S) -> Result<S::Ok, S::Error> {
+        ser.serialize_bytes(&vv.encode())
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<VersionVector, D::Error> {
+        let bytes: Vec<u8> = serde::Deserialize::deserialize(de)?;
+        VersionVector::decode(&bytes).map_err(serde::de::Error::custom)
+    }
+}
+
+/// Outcome of a sync exchange, returned by [`exchange`].
+pub struct SyncReport {
+    pub sent_bytes: usize,
+    pub received_bytes: usize,
+    pub imported: bool,
+}
+
+pub fn wormhole_config() -> AppConfig<serde_json::Value> {
+    AppConfig {
+        id: AppID::new(APP_ID),
+        rendezvous_url: Cow::Borrowed(magic_wormhole::rendezvous::DEFAULT_RENDEZVOUS_SERVER),
+        app_version: serde_json::json!({"v": 1}),
+    }
+}
+
+/// Run the sync protocol over an already-established wormhole.
+///
+/// Both sides call this concurrently.  The protocol is symmetric: each
+/// peer sends its version vector, receives the other's, computes a
+/// minimal delta, sends it, receives the peer's delta, and imports it.
+pub async fn exchange(store: &db::Store, mut wormhole: Wormhole) -> Result<SyncReport> {
+    let my_vv = store.doc().oplog_vv();
+    let my_handshake = SyncHandshake {
+        project_name: store.project_name().to_string(),
+        project_id: read_project_id(store)?,
+        version_vector: my_vv,
+    };
+
+    // --- Phase 1: exchange handshakes ---
+    wormhole
+        .send_json(&my_handshake)
+        .await
+        .context("failed to send handshake")?;
+
+    let their_handshake: SyncHandshake = wormhole
+        .receive_json::<SyncHandshake>()
+        .await
+        .context("failed to receive handshake")?
+        .context("peer sent invalid handshake JSON")?;
+
+    if my_handshake.project_id != their_handshake.project_id {
+        let _ = wormhole.close().await;
+        bail!(
+            "project identity mismatch: local '{}' ({}) vs peer '{}' ({})",
+            my_handshake.project_name,
+            my_handshake.project_id,
+            their_handshake.project_name,
+            their_handshake.project_id,
+        );
+    }
+
+    // --- Phase 2: compute and exchange deltas ---
+    let my_delta = store
+        .doc()
+        .export(ExportMode::updates(&their_handshake.version_vector))
+        .context("failed to export delta for peer")?;
+
+    wormhole
+        .send(my_delta.clone())
+        .await
+        .context("failed to send delta")?;
+
+    let their_delta = wormhole
+        .receive()
+        .await
+        .context("failed to receive delta from peer")?;
+
+    wormhole.close().await.context("failed to close wormhole")?;
+
+    // --- Phase 3: import the peer's delta locally ---
+    let imported = if !their_delta.is_empty() {
+        store
+            .doc()
+            .import(&their_delta)
+            .context("failed to import peer delta")?;
+        store.doc().commit();
+        store.save_raw_delta(&their_delta)?;
+        true
+    } else {
+        false
+    };
+
+    Ok(SyncReport {
+        sent_bytes: my_delta.len(),
+        received_bytes: their_delta.len(),
+        imported,
+    })
+}
+
+pub fn run(root: &Path, code: Option<&str>, json: bool) -> Result<()> {
+    let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
+    rt.block_on(run_async(root, code, json))
+}
+
+async fn run_async(root: &Path, code: Option<&str>, json: bool) -> Result<()> {
+    let store = db::open(root)?;
+    let c = crate::color::stderr_theme();
+
+    let wormhole = match code {
+        None => {
+            let mailbox = MailboxConnection::create(wormhole_config(), CODE_WORD_COUNT)
+                .await
+                .context("failed to create wormhole mailbox")?;
+
+            let code = mailbox.code().clone();
+            if json {
+                println!(
+                    "{}",
+                    serde_json::to_string(&serde_json::json!({"code": code.to_string()}))?
+                );
+            } else {
+                eprintln!("{}wormhole:{} run on the other machine:\n", c.blue, c.reset);
+                eprintln!("  td sync {}{}{}\n", c.bold, code, c.reset);
+                eprintln!("waiting for peer...");
+            }
+
+            Wormhole::connect(mailbox)
+                .await
+                .context("wormhole key exchange failed")?
+        }
+        Some(raw) => {
+            let code: Code = raw.parse().context("invalid wormhole code")?;
+            let mailbox = MailboxConnection::connect(wormhole_config(), code, false)
+                .await
+                .context("failed to connect to wormhole mailbox")?;
+
+            if !json {
+                eprintln!("{}wormhole:{} connecting...", c.blue, c.reset);
+            }
+
+            Wormhole::connect(mailbox)
+                .await
+                .context("wormhole key exchange failed")?
+        }
+    };
+
+    if !json {
+        eprintln!("{}wormhole:{} connected, syncing...", c.blue, c.reset);
+    }
+
+    let report = exchange(&store, wormhole).await?;
+
+    if json {
+        println!(
+            "{}",
+            serde_json::to_string(&serde_json::json!({
+                "synced": true,
+                "project": store.project_name(),
+                "sent_bytes": report.sent_bytes,
+                "received_bytes": report.received_bytes,
+            }))?
+        );
+    } else {
+        eprintln!(
+            "{}synced:{} {} (sent {} bytes, received {} bytes)",
+            c.green,
+            c.reset,
+            store.project_name(),
+            report.sent_bytes,
+            report.received_bytes,
+        );
+        if report.imported {
+            eprintln!("{}info:{} imported peer changes", c.blue, c.reset);
+        } else {
+            eprintln!("{}info:{} peer had no new changes", c.blue, c.reset);
+        }
+    }
+
+    Ok(())
+}
+
+/// Read the stable project identity from the doc's root meta map.
+fn read_project_id(store: &db::Store) -> Result<String> {
+    let root = serde_json::to_value(store.doc().get_deep_value())?;
+    root.get("meta")
+        .and_then(|m| m.get("project_id"))
+        .and_then(|v| v.as_str())
+        .map(str::to_owned)
+        .ok_or_else(|| anyhow::anyhow!("missing meta.project_id in project doc"))
+}

src/db.rs 🔗

@@ -331,6 +331,17 @@ impl Store {
         Ok(path)
     }
 
+    /// Persist pre-built delta bytes (e.g. received from a peer) as a new
+    /// change file without re-exporting from the doc.
+    pub fn save_raw_delta(&self, bytes: &[u8]) -> Result<PathBuf> {
+        let filename = format!("{}.loro", Ulid::new());
+        let path = project_dir(&self.root, &self.project)
+            .join(CHANGES_DIR)
+            .join(filename);
+        atomic_write_file(&path, bytes)?;
+        Ok(path)
+    }
+
     /// Return hydrated tasks, excluding tombstones.
     pub fn list_tasks(&self) -> Result<Vec<Task>> {
         self.list_tasks_inner(false)

tests/cli_sync.rs 🔗

@@ -0,0 +1,178 @@
+use assert_cmd::Command;
+use predicates::prelude::*;
+
+#[test]
+fn sync_help_shows_usage() {
+    let mut cmd = Command::cargo_bin("td").unwrap();
+    cmd.args(["sync", "--help"]);
+    cmd.assert()
+        .success()
+        .stdout(predicate::str::contains("Wormhole code"));
+}
+
+#[test]
+fn sync_invalid_code_format_fails() {
+    let home = tempfile::tempdir().unwrap();
+    let cwd = tempfile::tempdir().unwrap();
+
+    Command::cargo_bin("td")
+        .unwrap()
+        .args(["init", "synctest"])
+        .current_dir(cwd.path())
+        .env("HOME", home.path())
+        .assert()
+        .success();
+
+    let mut cmd = Command::cargo_bin("td").unwrap();
+    cmd.args(["sync", "not-a-valid-code"])
+        .current_dir(cwd.path())
+        .env("HOME", home.path());
+    cmd.assert().failure();
+}
+
+/// Two peers sync over a real wormhole connection.
+///
+/// Setup: both stores share the same project_id (simulating a project
+/// that was cloned to a second machine).  Each side creates a task the
+/// other doesn't have.  After sync, both should see both tasks.
+#[test]
+fn sync_exchanges_tasks_between_peers() {
+    use std::fs;
+    use yatd::db;
+
+    let home_a = tempfile::tempdir().unwrap();
+    let cwd_a = tempfile::tempdir().unwrap();
+    let home_b = tempfile::tempdir().unwrap();
+    let cwd_b = tempfile::tempdir().unwrap();
+
+    // --- Set up peer A: init a project and create a task ---
+    std::env::set_var("HOME", home_a.path());
+    let store_a = db::init(cwd_a.path(), "shared").unwrap();
+    let id_a = db::gen_id();
+    store_a
+        .apply_and_persist(|doc| {
+            let tasks = doc.get_map("tasks");
+            let task = db::insert_task_map(&tasks, &id_a)?;
+            task.insert("title", "task from A")?;
+            task.insert("description", "")?;
+            task.insert("type", "task")?;
+            task.insert("priority", "medium")?;
+            task.insert("status", "open")?;
+            task.insert("effort", "medium")?;
+            task.insert("parent", "")?;
+            task.insert("created_at", db::now_utc())?;
+            task.insert("updated_at", db::now_utc())?;
+            task.insert("deleted_at", "")?;
+            task.insert_container("labels", loro::LoroMap::new())?;
+            task.insert_container("blockers", loro::LoroMap::new())?;
+            task.insert_container("logs", loro::LoroMap::new())?;
+            Ok(())
+        })
+        .unwrap();
+
+    // --- Set up peer B: clone from A's snapshot, then add its own task ---
+    //
+    // Copy A's project directory so B has the same project_id and
+    // initial state, then create a separate device_id for B.
+    let data_a = home_a.path().join(".local/share/td/projects/shared");
+    let data_b = home_b.path().join(".local/share/td/projects/shared");
+    fs::create_dir_all(data_b.join("changes")).unwrap();
+    // Copy only the base snapshot — A's change deltas stay with A.
+    fs::copy(data_a.join("base.loro"), data_b.join("base.loro")).unwrap();
+
+    // Write a binding so db::open from cwd_b resolves to "shared".
+    let binding_dir = home_b.path().join(".local/share/td");
+    fs::create_dir_all(&binding_dir).unwrap();
+    let canonical_b = fs::canonicalize(cwd_b.path()).unwrap();
+    let bindings = serde_json::json!({
+        "bindings": {
+            canonical_b.to_string_lossy().to_string(): "shared"
+        }
+    });
+    fs::write(
+        binding_dir.join("bindings.json"),
+        serde_json::to_string_pretty(&bindings).unwrap(),
+    )
+    .unwrap();
+
+    std::env::set_var("HOME", home_b.path());
+    let store_b = db::open(cwd_b.path()).unwrap();
+    let id_b = db::gen_id();
+    store_b
+        .apply_and_persist(|doc| {
+            let tasks = doc.get_map("tasks");
+            let task = db::insert_task_map(&tasks, &id_b)?;
+            task.insert("title", "task from B")?;
+            task.insert("description", "")?;
+            task.insert("type", "task")?;
+            task.insert("priority", "high")?;
+            task.insert("status", "open")?;
+            task.insert("effort", "low")?;
+            task.insert("parent", "")?;
+            task.insert("created_at", db::now_utc())?;
+            task.insert("updated_at", db::now_utc())?;
+            task.insert("deleted_at", "")?;
+            task.insert_container("labels", loro::LoroMap::new())?;
+            task.insert_container("blockers", loro::LoroMap::new())?;
+            task.insert_container("logs", loro::LoroMap::new())?;
+            Ok(())
+        })
+        .unwrap();
+
+    // Verify pre-sync: A has 1 task, B has 2 (init snapshot + its own).
+    // A's delta hasn't been applied to B's snapshot yet, so B only sees
+    // tasks from the base snapshot plus its own delta.  Meanwhile A has
+    // the base snapshot plus its own delta.
+    let a_tasks_before = store_a.list_tasks().unwrap();
+    let b_tasks_before = store_b.list_tasks().unwrap();
+    assert_eq!(a_tasks_before.len(), 1, "A should have 1 task before sync");
+    assert_eq!(b_tasks_before.len(), 1, "B should have 1 task before sync");
+
+    // --- Sync via real wormhole ---
+    let rt = tokio::runtime::Runtime::new().unwrap();
+    rt.block_on(async {
+        use magic_wormhole::{MailboxConnection, Wormhole};
+        use yatd::cmd::sync::{exchange, wormhole_config};
+
+        // Peer A creates the mailbox.
+        let mailbox_a = MailboxConnection::create(wormhole_config(), 2)
+            .await
+            .unwrap();
+        let code = mailbox_a.code().clone();
+
+        // Peer B connects with the code.
+        let mailbox_b = MailboxConnection::connect(wormhole_config(), code, false)
+            .await
+            .unwrap();
+
+        // Both complete SPAKE2 key exchange concurrently.
+        let (wormhole_a, wormhole_b) =
+            tokio::try_join!(Wormhole::connect(mailbox_a), Wormhole::connect(mailbox_b),).unwrap();
+
+        // Run the sync protocol on both sides concurrently.
+        let (report_a, report_b) = tokio::try_join!(
+            exchange(&store_a, wormhole_a),
+            exchange(&store_b, wormhole_b),
+        )
+        .unwrap();
+
+        assert!(report_a.imported, "A should have imported B's changes");
+        assert!(report_b.imported, "B should have imported A's changes");
+        assert!(report_a.sent_bytes > 0);
+        assert!(report_b.sent_bytes > 0);
+    });
+
+    // --- Verify convergence ---
+    let a_tasks = store_a.list_tasks().unwrap();
+    let b_tasks = store_b.list_tasks().unwrap();
+
+    assert_eq!(a_tasks.len(), 2, "A should have 2 tasks after sync");
+    assert_eq!(b_tasks.len(), 2, "B should have 2 tasks after sync");
+
+    let a_titles: Vec<&str> = a_tasks.iter().map(|t| t.title.as_str()).collect();
+    let b_titles: Vec<&str> = b_tasks.iter().map(|t| t.title.as_str()).collect();
+    assert!(a_titles.contains(&"task from A"));
+    assert!(a_titles.contains(&"task from B"));
+    assert!(b_titles.contains(&"task from A"));
+    assert!(b_titles.contains(&"task from B"));
+}