11//! Capability-gated provider registration and NIP-OA activation.
22
3+ use std:: { future:: Future , sync:: Arc } ;
4+
35use tauri:: AppHandle ;
46
57use crate :: {
@@ -100,8 +102,53 @@ fn attestation_agent(
100102 } )
101103}
102104
103- fn attestation_pending_after ( was_pending : bool , succeeded : bool ) -> bool {
104- was_pending && !succeeded
105+ fn provider_operation_lock (
106+ state : & AppState ,
107+ pubkey : & str ,
108+ ) -> Result < Arc < tokio:: sync:: Mutex < ( ) > > , String > {
109+ let mut locks = state
110+ . provider_operation_locks
111+ . lock ( )
112+ . map_err ( |error| error. to_string ( ) ) ?;
113+ Ok ( Arc :: clone (
114+ locks
115+ . entry ( pubkey. to_string ( ) )
116+ . or_insert_with ( || Arc :: new ( tokio:: sync:: Mutex :: new ( ( ) ) ) ) ,
117+ ) )
118+ }
119+
120+ fn attestation_state_after (
121+ was_pending : bool ,
122+ result : & Result < ( ) , String > ,
123+ ) -> ( bool , Option < String > ) {
124+ (
125+ was_pending && result. is_err ( ) ,
126+ result. as_ref ( ) . err ( ) . cloned ( ) ,
127+ )
128+ }
129+
130+ async fn run_serialized_provider_operation <
131+ T ,
132+ ReachedBoundary ,
133+ BoundaryFuture ,
134+ Operation ,
135+ OperationFuture ,
136+ > (
137+ state : & AppState ,
138+ pubkey : & str ,
139+ reached_boundary : ReachedBoundary ,
140+ operation : Operation ,
141+ ) -> Result < T , String >
142+ where
143+ ReachedBoundary : FnOnce ( ) -> BoundaryFuture ,
144+ BoundaryFuture : Future < Output = ( ) > ,
145+ Operation : FnOnce ( ) -> OperationFuture ,
146+ OperationFuture : Future < Output = Result < T , String > > ,
147+ {
148+ let operation_lock = provider_operation_lock ( state, pubkey) ?;
149+ reached_boundary ( ) . await ;
150+ let _operation_guard = operation_lock. lock ( ) . await ;
151+ operation ( ) . await
105152}
106153
107154/// Attest the saved record in one community and persist a visible error if
@@ -118,7 +165,38 @@ pub(super) async fn attest(
118165 provider_config : & serde_json:: Value ,
119166 community_relay : & ScopedWorkspaceRelay ,
120167) -> Result < ( ) , String > {
121- let ( auth_tag, was_pending) = {
168+ run_serialized_provider_operation (
169+ state,
170+ pubkey,
171+ || async { } ,
172+ || async {
173+ attest_serialized (
174+ app,
175+ state,
176+ pubkey,
177+ provider_id,
178+ provider_config,
179+ community_relay,
180+ )
181+ . await
182+ } ,
183+ )
184+ . await
185+ }
186+
187+ /// Run while holding the per-agent provider-operation fence. Snapshot,
188+ /// provider I/O, and result persistence must stay inside this one operation;
189+ /// otherwise a slower failure can land after a newer success and reopen global
190+ /// attestation while overwriting its error state.
191+ async fn attest_serialized (
192+ app : & AppHandle ,
193+ state : & AppState ,
194+ pubkey : & str ,
195+ provider_id : & str ,
196+ provider_config : & serde_json:: Value ,
197+ community_relay : & ScopedWorkspaceRelay ,
198+ ) -> Result < ( ) , String > {
199+ let auth_tag = {
122200 let _guard = state
123201 . managed_agents_store_lock
124202 . lock ( )
@@ -128,14 +206,11 @@ pub(super) async fn attest(
128206 . iter ( )
129207 . find ( |record| record. pubkey == pubkey)
130208 . ok_or_else ( || format ! ( "agent {pubkey} not found" ) ) ?;
131- (
132- record
133- . auth_tag
134- . clone ( )
135- . filter ( |value| !value. is_empty ( ) )
136- . ok_or_else ( || format ! ( "agent {pubkey} has no auth tag" ) ) ?,
137- record. provider_attestation_pending ,
138- )
209+ record
210+ . auth_tag
211+ . clone ( )
212+ . filter ( |value| !value. is_empty ( ) )
213+ . ok_or_else ( || format ! ( "agent {pubkey} has no auth tag" ) ) ?
139214 } ;
140215
141216 let binary = resolve_provider_binary ( provider_id) ?;
@@ -153,19 +228,24 @@ pub(super) async fn attest(
153228 let mut records = load_managed_agents ( app) ?;
154229 let record = find_managed_agent_mut ( & mut records, pubkey) ?;
155230 record. updated_at = now_iso ( ) ;
156- record. last_error = result. as_ref ( ) . err ( ) . cloned ( ) ;
231+ let ( pending, last_error) =
232+ attestation_state_after ( record. provider_attestation_pending , & result) ;
233+ record. last_error = last_error;
157234 // A failed first attest leaves activation pending. A later community's
158235 // enrollment failure must not make an already activated agent globally
159236 // undeployed; the scoped Start call still returns the failure to its caller.
160- record. provider_attestation_pending = attestation_pending_after ( was_pending , result . is_ok ( ) ) ;
237+ record. provider_attestation_pending = pending ;
161238 save_managed_agents ( app, & records) ?;
162239 result
163240}
164241
165242#[ cfg( test) ]
166243mod tests {
167244 use super :: * ;
245+ use crate :: app_state:: build_app_state;
168246 use crate :: relay:: bind_expected_relay_scope;
247+ use tokio:: sync:: { Barrier , Notify } ;
248+ use tokio:: time:: { timeout, Duration } ;
169249
170250 #[ test]
171251 fn attestation_payload_binds_the_target_community ( ) {
@@ -185,12 +265,80 @@ mod tests {
185265 ) ;
186266 }
187267
268+ #[ tokio:: test]
269+ async fn concurrent_attestation_attempts_are_serialized_per_agent ( ) {
270+ let state = Arc :: new ( build_app_state ( ) ) ;
271+ let first_entered = Arc :: new ( Notify :: new ( ) ) ;
272+ let release_first = Arc :: new ( Notify :: new ( ) ) ;
273+ let first_state = Arc :: clone ( & state) ;
274+ let first_entered_task = Arc :: clone ( & first_entered) ;
275+ let release_first_task = Arc :: clone ( & release_first) ;
276+ let first = tokio:: spawn ( async move {
277+ run_serialized_provider_operation (
278+ & first_state,
279+ "agent-pubkey" ,
280+ || async { } ,
281+ || async {
282+ first_entered_task. notify_one ( ) ;
283+ release_first_task. notified ( ) . await ;
284+ Ok ( ( ) )
285+ } ,
286+ )
287+ . await
288+ . unwrap ( ) ;
289+ } ) ;
290+ first_entered. notified ( ) . await ;
291+
292+ let second_at_boundary = Arc :: new ( Barrier :: new ( 2 ) ) ;
293+ let second_entered = Arc :: new ( Notify :: new ( ) ) ;
294+ let second_state = Arc :: clone ( & state) ;
295+ let second_at_boundary_task = Arc :: clone ( & second_at_boundary) ;
296+ let second_entered_task = Arc :: clone ( & second_entered) ;
297+ let second = tokio:: spawn ( async move {
298+ run_serialized_provider_operation (
299+ & second_state,
300+ "agent-pubkey" ,
301+ || async {
302+ second_at_boundary_task. wait ( ) . await ;
303+ } ,
304+ || async {
305+ second_entered_task. notify_one ( ) ;
306+ Ok ( ( ) )
307+ } ,
308+ )
309+ . await
310+ . unwrap ( ) ;
311+ } ) ;
312+
313+ second_at_boundary. wait ( ) . await ;
314+ assert ! (
315+ timeout( Duration :: from_millis( 100 ) , second_entered. notified( ) )
316+ . await
317+ . is_err( )
318+ ) ;
319+ release_first. notify_one ( ) ;
320+ first. await . unwrap ( ) ;
321+ second. await . unwrap ( ) ;
322+ }
323+
188324 #[ test]
189- fn later_community_failure_does_not_reopen_global_attestation ( ) {
190- assert ! ( attestation_pending_after( true , false ) ) ;
191- assert ! ( !attestation_pending_after( true , true ) ) ;
192- assert ! ( !attestation_pending_after( false , false ) ) ;
193- assert ! ( !attestation_pending_after( false , true ) ) ;
325+ fn serialized_attestation_results_preserve_the_authoritative_completion ( ) {
326+ let success = Ok ( ( ) ) ;
327+ let failure = Err ( "later community failed" . to_string ( ) ) ;
328+
329+ let ( pending, error) = attestation_state_after ( true , & success) ;
330+ assert ! ( !pending) ;
331+ assert_eq ! ( error, None ) ;
332+ let ( pending, error) = attestation_state_after ( pending, & failure) ;
333+ assert ! ( !pending) ;
334+ assert_eq ! ( error. as_deref( ) , Some ( "later community failed" ) ) ;
335+
336+ let ( pending, error) = attestation_state_after ( true , & failure) ;
337+ assert ! ( pending) ;
338+ assert_eq ! ( error. as_deref( ) , Some ( "later community failed" ) ) ;
339+ let ( pending, error) = attestation_state_after ( pending, & success) ;
340+ assert ! ( !pending) ;
341+ assert_eq ! ( error, None ) ;
194342 }
195343
196344 #[ test]
0 commit comments