@@ -480,10 +480,69 @@ def sync_receipts(store: GuardStore) -> dict[str, object]:
480480 "inventory" : 0 ,
481481 "inventory_tracked" : len (inventory ),
482482 }
483+ summary ["guard_events_v1" ] = sync_guard_events (store )
483484 store .set_sync_payload ("sync_summary" , summary , now )
484485 return summary
485486
486487
488+ def sync_guard_events (store : GuardStore ) -> dict [str , object ]:
489+ """Push pending GuardEventV1 envelopes to Guard Cloud."""
490+
491+ credentials = store .get_sync_credentials ()
492+ if credentials is None :
493+ raise GuardSyncNotConfiguredError ("Guard is not logged in." )
494+ sync_url = _guard_events_sync_url (str (credentials ["sync_url" ]))
495+ total_events = 0
496+ total_accepted = 0
497+ synced_at = _now ()
498+ while True :
499+ pending_events = store .list_guard_events_v1 (uploaded = False , limit = 200 )
500+ if not pending_events :
501+ break
502+ body = json .dumps ({"events" : [event ["payload" ] for event in pending_events ]}).encode ("utf-8" )
503+ request = urllib .request .Request (
504+ sync_url ,
505+ data = body ,
506+ method = "POST" ,
507+ headers = _guard_sync_headers (str (credentials ["token" ])),
508+ )
509+ try :
510+ payload = _urlopen_json_with_timeout_retry (
511+ request = request ,
512+ timeout_seconds = _SYNC_HTTP_TIMEOUT_SECONDS ,
513+ retry_timeout_seconds = _SYNC_HTTP_RETRY_TIMEOUT_SECONDS ,
514+ )
515+ except urllib .error .HTTPError as error :
516+ if error .code == 404 :
517+ summary = {
518+ "synced_at" : synced_at ,
519+ "events" : total_events ,
520+ "accepted" : total_accepted ,
521+ "sync_skipped" : True ,
522+ "sync_reason" : "guard_events_endpoint_unavailable" ,
523+ }
524+ store .set_sync_payload ("guard_events_v1_summary" , summary , synced_at )
525+ return summary
526+ if error .code == 403 :
527+ is_plan , message = _check_plan_restriction_403 (error )
528+ if is_plan :
529+ raise GuardSyncNotAvailableError (message ) from error
530+ raise RuntimeError (message ) from error
531+ raise RuntimeError (_sync_http_error_message (error )) from error
532+ except OSError as error :
533+ raise RuntimeError (_sync_url_error_message (error )) from error
534+ completed_ids = _completed_guard_event_ids (payload )
535+ synced_at = _sync_timestamp (payload )
536+ uploaded = store .mark_guard_events_v1_uploaded (completed_ids , synced_at )
537+ total_events += len (pending_events )
538+ total_accepted += uploaded
539+ if uploaded == 0 or len (pending_events ) < 200 :
540+ break
541+ summary = {"synced_at" : synced_at , "events" : total_events , "accepted" : total_accepted }
542+ store .set_sync_payload ("guard_events_v1_summary" , summary , synced_at )
543+ return summary
544+
545+
487546def sync_runtime_session (
488547 store : GuardStore ,
489548 * ,
@@ -951,6 +1010,45 @@ def _normalized_runtime_sessions_sync_url(sync_url: str) -> str:
9511010 )
9521011
9531012
1013+ def _guard_events_sync_url (sync_url : str ) -> str :
1014+ parsed = urllib .parse .urlsplit (_normalized_receipts_sync_url (sync_url ))
1015+ if parsed .path .rstrip ("/" ).endswith ("/api/v1/guard/events" ):
1016+ return urllib .parse .urlunsplit ((parsed .scheme , parsed .netloc , parsed .path .rstrip ("/" ), parsed .query , "" ))
1017+ path = parsed .path .rstrip ("/" )
1018+ for suffix in (
1019+ "/api/guard/receipts/sync" ,
1020+ "/guard/receipts/sync" ,
1021+ "/registry/api/v1/guard/receipts/sync" ,
1022+ ):
1023+ if path .endswith (suffix ):
1024+ path = path [: - len (suffix )]
1025+ break
1026+ return urllib .parse .urlunsplit (
1027+ (
1028+ parsed .scheme ,
1029+ parsed .netloc ,
1030+ path .rstrip ("/" ) + "/api/v1/guard/events" ,
1031+ parsed .query ,
1032+ "" ,
1033+ )
1034+ )
1035+
1036+
1037+ def _completed_guard_event_ids (payload : dict [str , object ]) -> list [str ]:
1038+ statuses = payload .get ("statuses" )
1039+ if not isinstance (statuses , list ):
1040+ return []
1041+ completed : list [str ] = []
1042+ for item in statuses :
1043+ if not isinstance (item , dict ):
1044+ continue
1045+ status = str (item .get ("status" ) or "" )
1046+ event_id = item .get ("eventId" )
1047+ if status in {"accepted" , "duplicate" , "rejected" } and isinstance (event_id , str ):
1048+ completed .append (event_id )
1049+ return completed
1050+
1051+
9541052def _cloud_sync_receipts_payload (store : GuardStore , receipts : list [dict [str , object ]]) -> list [dict [str , object ]]:
9551053 device_id , device_name = _guard_device_metadata (store )
9561054 return [_cloud_sync_receipt_payload (receipt , device_id = device_id , device_name = device_name ) for receipt in receipts ]
0 commit comments