phase A wire-up: LogSync → ZodiaPipeline → AppMsg

Plumbs the inbound LogSync stream through ZodiaPipeline.  The op flow
   end-to-end:

     remote peer → LogSync sub → node.inbound_ops → pipeline.process(op)
       → pipeline.next() → AppMsg::SyncStateEvent → store.insert_from_op
       → recent_interps refresh + UI bump

   Publish goes the other way: AppMsg::ShareInterp now packages the entry
   as InterpOp::Author and forwards it to node.publish(op), which CBOR-
   encodes, signs the p2panda header, persists to the operation log, and
   as InterpOp::Author and forwards it to node.publish(op), which CBOR-
   encodes, signs the p2panda header, persists to the operation log, and
   broadcasts via LogSync.

   Changes:

     * zodia-sync: drop InterpPayload (legacy Zodia-sig wrapper), drop
       ReceivedInterp, drop the ZodiaStore dep entirely.  Expose raw
       Operation<()> on `inbound_ops` and accept InterpOp on publish.
       The subscription task is now a thin forwarder — decoding lives in
       the pipeline.
     * zodia-store: add insert_from_op(key, body, author_pk) that trusts
       its caller (the pipeline) for authentication and leaves author_sig
       NULL.  Tier-1 community_for_keys still filters on
       author_sig IS NOT NULL so re-sharing is unaffected.
     * app: SyncInterpReceived → SyncStateEvent(StateEvent); the
       SyncPublishMsg::Publish payload becomes a plain InterpOp; the pump
       task moves from tokio::spawn to glib::MainContext::default()
       .spawn_local because ZodiaPipeline is !Send (single-threaded by
       design).
     * sync/Cargo.toml: zodia-store dep removed, zodia-ops dep added.

   Wire-format break: peers on this branch encode InterpOp::Author instead
   of the legacy InterpPayload struct.  Old peers' bodies will produce
   StateEvent::Skipped { MalformedOp } and be dropped silently.  That's
   acceptable for a feature branch; the release note will flag it when
   this lands.

   Workspace builds clean.  8/8 unit tests pass across zodia-ops +
   zodia-pipeline.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-05-28 21:21:12 +02:00
parent 8597edb8b6
commit 36fb5c886b
6 changed files with 160 additions and 194 deletions
+2
View File
@@ -17,6 +17,8 @@ zodia-config.workspace = true
zodia-store.workspace = true
zodia-av.workspace = true
zodia-sync.workspace = true
zodia-ops.workspace = true
zodia-pipeline.workspace = true
p2panda-core = "0.6"
ciborium.workspace = true
blake3.workspace = true
+71 -39
View File
@@ -30,7 +30,9 @@ use zodia_crypto::{ecies_decrypt, ecies_encrypt};
use zodia_net::{ChannelMsg, ConsentBlob, DirectChannel, InterpEntry,
NetworkConfig, PeerId, PeerStatus, RelayPayload, ZodiaNetEvent, ZodiaNetwork};
use zodia_store::{StoreError, ZodiaStore, BaselineStore};
use zodia_sync::{ReceivedInterp, ZodiaSyncNode};
use zodia_sync::ZodiaSyncNode;
use zodia_ops::InterpOp;
use zodia_pipeline::{StateEvent, ZodiaPipeline};
use relm4::factory::FactoryVecDeque;
@@ -127,8 +129,10 @@ pub enum AppMsg {
GoingOffline,
/// User submitted a new interpretation — broadcast it to all live peers.
ShareInterp(InterpEntry),
/// A new community interpretation arrived via p2panda LogSync.
SyncInterpReceived(ReceivedInterp),
/// A typed state event from the inbound `ZodiaPipeline`. Replaces
/// the legacy `SyncInterpReceived` path: now everything that arrives
/// over LogSync flows through the pipeline first.
SyncStateEvent(StateEvent),
/// User tapped the affirm button on a community interpretation row.
AffirmInterp { log_id: [u8; 32] },
/// User submitted a fresh community interpretation from a detail page.
@@ -847,27 +851,47 @@ impl AsyncComponent for AppModel {
}
// Slow path: publish to the p2panda log for offline catch-up sync.
if let Some(tx) = &self.sync_publish_tx {
if entry.author_sig.len() == 64 {
let mut sig = [0u8; 64];
sig.copy_from_slice(&entry.author_sig);
let _ = tx.try_send(SyncPublishMsg::Publish {
interp_key: entry.interp_key,
body: entry.body,
author_sig: sig,
});
}
let op = InterpOp::Author {
interp_key: entry.interp_key,
body: entry.body,
};
let _ = tx.try_send(SyncPublishMsg::Publish(op));
}
}
AppMsg::SyncInterpReceived(interp) => {
debug!(
key = %interp.interp_key,
author = %hex::encode(&interp.author_pk[..4]),
"new interpretation received via sync"
);
// Reload activity feed and trigger a network view refresh.
self.recent_interps = self.store
.recent_community_interps(12).await.unwrap_or_default();
self.network_changed_token += 1;
AppMsg::SyncStateEvent(event) => {
match event {
StateEvent::InterpAuthored { author, interp_key, body, .. } => {
let author_pk: [u8; 32] = *author.as_bytes();
match self.store
.insert_from_op(&interp_key, &body, &author_pk)
.await
{
Ok(true) => {
debug!(
key = %interp_key,
author = %hex::encode(&author_pk[..4]),
"interp authored via sync — stored"
);
self.recent_interps = self.store
.recent_community_interps(12).await.unwrap_or_default();
self.network_changed_token += 1;
}
Ok(false) => {} // duplicate, nothing to do
Err(e) => warn!("sync insert_from_op failed: {e}"),
}
}
StateEvent::AffirmAdded { .. } => {
// Phase B will wire affirmations into a store projection.
// Today: log + bump so the UI knows something changed.
self.network_changed_token += 1;
}
StateEvent::ResponseAdded { .. } => {
// Phase C will wire response threading. No-op for now.
}
StateEvent::Skipped { reason } => {
debug!(?reason, "sync op skipped");
}
}
}
AppMsg::AffirmInterp { log_id } => {
let author_pk = self.identity.public_key();
@@ -1555,14 +1579,21 @@ async fn try_spawn_network(
/// Message type for sending publish requests to the background sync task.
pub(crate) enum SyncPublishMsg {
Publish { interp_key: String, body: String, author_sig: [u8; 64] },
Publish(InterpOp),
}
/// Spawn the LogSync background task and return a channel for publishing.
/// Spawn the LogSync background pump and return a channel for publishing.
///
/// Opens a second connection to the same SQLite file so the sync task can
/// call `insert_received` without conflicting with the main-thread store
/// (WAL mode allows concurrent readers + one writer).
/// Architecture:
/// - `ZodiaSyncNode` exposes raw `Operation<()>` on `inbound_ops`.
/// - A `ZodiaPipeline` decodes / materialises each op into `StateEvent`s.
/// - We dispatch each `StateEvent` back to the model as `AppMsg::SyncStateEvent`.
/// - Outbound publishes (`SyncPublishMsg::Publish(InterpOp)`) go straight
/// to `node.publish`.
///
/// The pipeline is `!Send` (p2panda-stream is single-threaded by design),
/// so the pump task runs on glib's main-thread context via
/// `spawn_future_local`, not on tokio's multi-thread runtime.
async fn try_spawn_sync(
config: &LocalConfig,
net: &ZodiaNetwork,
@@ -1570,12 +1601,6 @@ async fn try_spawn_sync(
) -> Option<tokio::sync::mpsc::Sender<SyncPublishMsg>> {
use zodia_core::topic_key_global;
let store_path = config.data_dir().join("interpretations.db");
let sync_store = match ZodiaStore::open(&store_path).await {
Ok(s) => s,
Err(e) => { warn!("sync store open failed: {e}"); return None; }
};
let signing_key = config.identity.to_panda_key();
let topic = p2panda_core::Topic::from(topic_key_global().0);
@@ -1583,7 +1608,6 @@ async fn try_spawn_sync(
signing_key,
net.endpoint(),
net.gossip(),
sync_store,
topic,
config.data_dir(),
).await {
@@ -1594,21 +1618,29 @@ async fn try_spawn_sync(
let (tx, mut rx) = tokio::sync::mpsc::channel::<SyncPublishMsg>(32);
let sender_bg = sender.clone();
tokio::spawn(async move {
glib::MainContext::default().spawn_local(async move {
let mut node = node;
let pipeline = ZodiaPipeline::new();
loop {
tokio::select! {
Some(msg) = rx.recv() => {
match msg {
SyncPublishMsg::Publish { interp_key, body, author_sig } => {
if let Err(e) = node.publish(&interp_key, &body, &author_sig).await {
SyncPublishMsg::Publish(op) => {
if let Err(e) = node.publish(op).await {
warn!("sync publish: {e}");
}
}
}
}
Some(interp) = node.received.recv() => {
sender_bg.input(AppMsg::SyncInterpReceived(interp));
Some(op) = node.inbound_ops.recv() => {
if pipeline.process(op).await.is_err() {
warn!("pipeline closed unexpectedly");
break;
}
match pipeline.next().await {
Ok(event) => sender_bg.input(AppMsg::SyncStateEvent(event)),
Err(e) => warn!("pipeline next: {e}"),
}
}
else => break,
}