diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 25e1ff0..fc47a83 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -30,7 +30,7 @@ pub use repo::RepoStore; pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; -pub use state::{GitRef, SyncState}; +pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState}; pub use sync_status::{CheckoutSyncStatus, SyncStatusStore}; pub fn init( diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index 6615ee8..ca95968 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -51,16 +51,16 @@ pub struct GraspServer { } impl GraspServer { - fn ok(relay: RelayUrl) -> Self { + pub(crate) fn ok(relay: RelayUrl) -> Self { Self { relay, reason: None, } } - fn failed(relay: RelayUrl, reason: impl Into) -> Self { + pub(crate) fn failed(relay: &RelayUrl, reason: impl Into) -> Self { Self { - relay, + relay: relay.to_owned(), reason: Some(reason.into()), } } @@ -150,7 +150,7 @@ where let Some(base) = grasp_base_url(relay) else { outcome .servers - .push(GraspServer::failed(relay.clone(), "no domain")); + .push(GraspServer::failed(relay, "no domain")); continue; }; @@ -161,9 +161,7 @@ where Ok(event) => event, Err(e) => { let reason = format!("could not sign the state event: {e}"); - outcome - .servers - .push(GraspServer::failed(relay.clone(), reason)); + outcome.servers.push(GraspServer::failed(relay, reason)); continue; } }; @@ -183,10 +181,7 @@ where }; if let Err(reason) = staging { - log::warn!("grasp push failed: {relay}: {reason}"); - outcome - .servers - .push(GraspServer::failed(relay.clone(), reason)); + outcome.servers.push(GraspServer::failed(relay, reason)); continue; } @@ -196,10 +191,9 @@ where outcome.servers.push(GraspServer::ok(relay.clone())); } Err(e) => { - log::warn!("grasp push failed: {relay}: {e}"); outcome .servers - .push(GraspServer::failed(relay.clone(), e.to_string())); + .push(GraspServer::failed(relay, e.to_string())); } } } diff --git a/crates/signed_state/src/state.rs b/crates/signed_state/src/state.rs index c60556b..a8102f7 100644 --- a/crates/signed_state/src/state.rs +++ b/crates/signed_state/src/state.rs @@ -1,8 +1,15 @@ +use std::path::{Path, PathBuf}; use std::str::FromStr; -use anyhow::{Error, anyhow}; +use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; +use gpui::{AppContext, AsyncApp}; use nostr::prelude::*; +use nostr_sdk::prelude::*; +use signed_core::Announcement; +use signed_git::Repo; + +use crate::push::{GraspServer, PushOutcome, grasp_base_url}; /// A git ref with the commit it points at. #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,10 +30,41 @@ impl GitRef { } } +/// How much to push to the grasp servers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GitPushMode { + /// Mirror all branches and tags. + #[default] + All, + /// Push `refs/heads/main` only (repo creation). + Main, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyncState { pub refs: Vec, pub head: Option, + /// Local repository to push from; `None` for states parsed from events. + pub path: Option, + pub mode: GitPushMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PushStep { + AnnounceRepository, + /// Publish the state event to one grasp server. + StageStateEvent(RelayUrl), + /// Git push to one grasp server. + PushObjects(RelayUrl), + /// Fan the state event out to the relays. + BroadcastStateEvent, +} + +#[derive(Debug, Clone)] +pub enum PushStepStatus { + Running(PushStep), + Done(PushStep), + Failed(PushStep, String), } impl SyncState { @@ -70,7 +108,243 @@ impl SyncState { } } - Self { refs, head } + Self { + refs, + head, + path: None, + mode: GitPushMode::default(), + } + } + + /// Reads the refs and head of a local repository, ready to push. + pub fn from_repo(path: &Path) -> Result { + let repo = Repo::open(path)?; + let state = repo.ref_state()?; + + let refs = state + .refs + .iter() + .map(|(name, commit)| GitRef::new(name, commit)) + .collect::, Error>>()?; + + Ok(Self { + refs, + head: state.head, + path: Some(path.to_path_buf()), + mode: GitPushMode::default(), + }) + } + + pub fn with_mode(mut self, mode: GitPushMode) -> Self { + self.mode = mode; + self + } + + /// State event for these refs; the caller signs it. + pub fn state_event(&self, repo_id: &str) -> EventBuilder { + Self::build(repo_id, &self.refs, self.head.as_deref()) + } + + /// Push the state event and objects to the grasp servers, + /// calling `on_step` for each step. + pub async fn push_to_grasps

( + &self, + client: &Client, + event: Event, + announcement: &Announcement, + cx: &mut AsyncApp, + on_step: &mut P, + ) -> Result + where + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, + { + let path = self + .path + .as_deref() + .ok_or_else(|| anyhow!("push requires a local repository"))?; + + let owner = announcement + .owner + .to_bech32() + .map_err(|error| anyhow!("could not encode the repository owner: {error}"))?; + + let mut outcome = PushOutcome::default(); + + for relay in &announcement.relays { + let step = PushStep::StageStateEvent(relay.clone()); + + // The relay must accept the state event before the git push is allowed. + on_step(PushStepStatus::Running(step.clone()), cx)?; + + // Get base URL for the git push + let base = grasp_base_url(relay).unwrap_or_default(); + + // Ensure the relay is connected before sending the event. + client.add_relay(relay).and_connect().await?; + + let staging = client.send_event(&event).to([relay.clone()]).await; + // Whether the relay accepted the event + let sent_failed = !staging + .as_ref() + .is_ok_and(|output| output.success.contains_key(relay)); + + if sent_failed { + let reason = match &staging { + Ok(output) => output + .failed + .get(relay) + .cloned() + .unwrap_or_else(|| "relay did not confirm the event".to_owned()), + Err(e) => format!("could not send the state event to {relay}: {e}"), + }; + + // Run the callback to report the failure + on_step(PushStepStatus::Failed(step, reason.clone()), cx)?; + + // Record the failure in the outcome and continue to the next relay + outcome.servers.push(GraspServer::failed(relay, reason)); + continue; + } + + // Run the callback to report the success + on_step(PushStepStatus::Done(step), cx)?; + + // Run the git push in the background, reporting progress through the callback + let step = PushStep::PushObjects(relay.clone()); + on_step(PushStepStatus::Running(step.clone()), cx)?; + + let pushed = cx.background_spawn({ + let path = path.to_path_buf(); + let base = base.clone(); + let owner = owner.clone(); + let repo_id = announcement.id.clone(); + let mode = self.mode; + + async move { + let repo = Repo::open(&path)?; + match mode { + GitPushMode::Main => repo.push_main(&base, &owner, &repo_id), + GitPushMode::All => repo.push_all(&base, &owner, &repo_id), + } + } + }); + + match pushed.await { + Ok(()) => { + // Run the callback to report the success + on_step(PushStepStatus::Done(step), cx)?; + + outcome.state_event = Some(event.clone()); + outcome.servers.push(GraspServer::ok(relay.clone())); + } + Err(e) => { + // Run the callback to report the failure + on_step(PushStepStatus::Failed(step, e.to_string()), cx)?; + + outcome + .servers + .push(GraspServer::failed(relay, e.to_string())); + } + } + } + + // Fan the state out to the relays once a git server holds the objects. + if outcome.state_event.is_some() { + let step = PushStep::BroadcastStateEvent; + on_step(PushStepStatus::Running(step.clone()), cx)?; + + if let Err(e) = client.send_event(&event).broadcast().await { + on_step(PushStepStatus::Failed(step, e.to_string()), cx)?; + log::warn!("failed to broadcast repository state: {e}"); + } else { + on_step(PushStepStatus::Done(step), cx)?; + } + } + + Ok(outcome) + } + + /// Announce the repository and push the state and objects to the grasp servers. + /// + /// Both events are signed by the caller, so a failure at the call site can retract the announcement by id. + pub async fn announce_and_push

( + &self, + client: &Client, + announcement: Event, + state_event: Event, + cx: &mut AsyncApp, + on_step: &mut P, + ) -> Result + where + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, + { + let step = PushStep::AnnounceRepository; + on_step(PushStepStatus::Running(step.clone()), cx)?; + + let published = Announcement::from_event(&announcement) + .ok_or_else(|| anyhow!("failed to parse announcement"))?; + + // Ensure the relays are connected before sending the announcement event. + for url in &published.relays { + client.add_relay(url).and_connect().await.ok(); + } + + let output = client.send_event(&announcement).broadcast().await?; + + // Handle the case where no relay accepted the announcement event. + if output.success.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + + // Run the callback to report the failure + let step = PushStepStatus::Failed(step, format!("event not accepted: {reasons}")); + on_step(step, cx)?; + + bail!("event not accepted: {reasons}"); + } + + on_step(PushStepStatus::Done(step), cx)?; + + let outcome = if self.refs.is_empty() { + PushOutcome::default() + } else { + self.push_to_grasps(client, state_event, &published, cx, on_step) + .await? + }; + + if outcome.accepted() == 0 { + bail!( + "Repository was announced, but the push to grasp servers failed: {}", + outcome.failure_summary() + ); + } + + Ok(announcement) + } + + /// Retracts a live announcement; call when `announce_and_push` fails. + pub async fn retract_announcement( + client: &Client, + signer: &S, + id: EventId, + ) -> Result<(), Error> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { + let builder = EventDeletionRequest::new().id(id); + let deletion = builder.finalize_async(signer).await?; + let output = client.send_event(&deletion).broadcast().await?; + + // Without relay acceptance the deletion request stays live. + if output.success.is_empty() { + bail!("deletion event not accepted by any relay"); + } + + Ok(()) } } diff --git a/docs/push-refactor.md b/docs/push-refactor.md index 5aeedde..d2c3b29 100644 --- a/docs/push-refactor.md +++ b/docs/push-refactor.md @@ -37,7 +37,8 @@ Mechanical, no behavior change. Replace `push_staged_to_grasps` with methods on the type that owns the refs: ```rust -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// How much to push to the grasp servers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum GitPushMode { /// Mirror all branches and tags. #[default] @@ -106,7 +107,7 @@ impl SyncState { // via cx.background_spawn (blocking gix work). Reports every transition // through on_step. Ends with the relay fan-out broadcast. Errors once // up front when self.path is None (state not built from a local repo). - pub(crate) async fn push_to_grasps

( + pub async fn push_to_grasps

( &self, cx: &mut AsyncApp, client: &Client, @@ -118,11 +119,10 @@ impl SyncState { P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, { ... } - // announce_repository_and_push moved as-is: sign + broadcast the - // announcement (AnnounceRepository step), parse it into an - // `Announcement`, sign the state event, delegate to push_to_grasps, - // retract on total failure. This one keeps the signer. - pub(crate) async fn announce_and_push(...) -> Result { ... } + // Announces the pre-signed announcement (AnnounceRepository step), then + // delegates to push_to_grasps with the pre-signed state event. No signer: + // on failure the call site retracts the announcement by id. + pub async fn announce_and_push

(...) -> Result { ... } } ``` @@ -132,7 +132,7 @@ The struct carries the job description — what (`refs`, `head`), from where (`p - `refs`, `head`, `path`, `mode` come from `&self`. The `push: fn(&Path, &str, &str, &str) -> Result<(), Error>` callback becomes the `mode` field, resolved inside to `Repo::push_main` / `Repo::push_all`; the wildcard-refspec mirror semantics of `push_all` are preserved. States built by `SyncState::parse` (remote events) have no local repo, so `path` is `Option` and `push_to_grasps` errors once up front when it is `None`. - `repo_id`, `owner`, `servers` collapse into `&Announcement` — its `id`, `owner` and `relays` are exactly those values, and both call sites already hold one. `push_repo_from` currently destructures the announcement into those three fields just to re-pass them. It stays an argument because states parsed from events carry no announcement. -- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives with the callers (`push_repo_from`, `announce_and_push`, which also signs the announcement and the retraction). +- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives entirely with the callers: `announce_and_push` takes the pre-signed announcement and state events, so a failing call site can retract the announcement by id. - `client` stays as a reference per the constraints; `SyncState` remains plain data, so `SyncState::parse` keeps working for remote states that have no client or path. - `on_step` takes `&mut AsyncApp` so it can update an entity directly. That is what lets the whole flow run in the caller's foreground `cx.spawn` task and emit progress without a channel. @@ -147,7 +147,7 @@ The struct carries the job description — what (`refs`, `head`), from where (`p - Add `BackendEvent::PushProgress { addr: RepoAddr, status: PushStepStatus }`. - `push_repo_from`: background-spawn `SyncState::from_repo(&path)`, sign `state.state_event(&announcement.id)` with the signer, then call `push_to_grasps(cx, &client, event, &announcement, on_step)` in the same foreground task, `on_step` emitting the event via `this.update`. The nested `background_spawn` and its clone block disappear. Keep the `refs.is_empty()` early return at the call site. -- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), then call `announce_and_push(cx, client, signer, announcement, on_step)`. +- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), sign the announcement and state events, then call `announce_and_push(client, announcement, state_event, cx, on_step)`. When it fails, the call site retracts via `SyncState::retract_announcement` — retraction is the very end of the flow, not inside the method. - `push.rs` keeps `GraspServer`, `PushOutcome` and the URL helpers. ## Step 4 — UI checklist