Move push flow into SyncState methods with step callbacks
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<String>) -> Self {
|
||||
pub(crate) fn failed(relay: &RelayUrl, reason: impl Into<String>) -> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GitRef>,
|
||||
pub head: Option<String>,
|
||||
/// Local repository to push from; `None` for states parsed from events.
|
||||
pub path: Option<PathBuf>,
|
||||
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<Self, Error> {
|
||||
let repo = Repo::open(path)?;
|
||||
let state = repo.ref_state()?;
|
||||
|
||||
let refs = state
|
||||
.refs
|
||||
.iter()
|
||||
.map(|(name, commit)| GitRef::new(name, commit))
|
||||
.collect::<Result<Vec<_>, 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<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
event: Event,
|
||||
announcement: &Announcement,
|
||||
cx: &mut AsyncApp,
|
||||
on_step: &mut P,
|
||||
) -> Result<PushOutcome, Error>
|
||||
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<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
announcement: Event,
|
||||
state_event: Event,
|
||||
cx: &mut AsyncApp,
|
||||
on_step: &mut P,
|
||||
) -> Result<Event, Error>
|
||||
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::<Vec<String>>()
|
||||
.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<S>(
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<P>(
|
||||
pub async fn push_to_grasps<P>(
|
||||
&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<S, P>(...) -> Result<Event, Error> { ... }
|
||||
// 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<P>(...) -> Result<Event, Error> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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<PathBuf>` 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
|
||||
|
||||
Reference in New Issue
Block a user