@agent-native/core 0.173.1 → 0.173.2-nightly-20260825163448

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/desktop/src/app.tsx +77 -7
  3. package/corpus/templates/clips/desktop/src/lib/recorder.ts +91 -25
  4. package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +20 -2
  5. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +7 -1
  6. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  7. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +372 -70
  8. package/corpus/templates/clips/server/lib/resumable-session.ts +15 -0
  9. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +245 -118
  10. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +21 -11
  11. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +132 -25
  12. package/corpus/templates/content/actions/_database-property-input.ts +211 -0
  13. package/corpus/templates/content/actions/_database-row-mutation.ts +151 -26
  14. package/corpus/templates/content/actions/add-database-item.ts +15 -5
  15. package/corpus/templates/content/actions/update-database-item.ts +15 -7
  16. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +15 -5
  17. package/corpus/templates/content/package.json +1 -0
  18. package/corpus/templates/content/parity/README.md +11 -2
  19. package/corpus/templates/content/parity/eval-scenarios.ts +52 -0
  20. package/corpus/templates/content/parity/run-database-create-property-preservation.ts +140 -0
  21. package/corpus/templates/content/parity/scenario-to-eval.ts +390 -0
  22. package/dist/agent/run-store.js +0 -1
  23. package/dist/client/agent-engine-key.js +26 -4
  24. package/dist/client/settings/ProviderSetupForm.js +1 -1
  25. package/dist/collab/struct-routes.d.ts +1 -1
  26. package/dist/eval/agent-runner.js +23 -0
  27. package/dist/eval/types.d.ts +11 -0
  28. package/dist/observability/routes.d.ts +1 -1
  29. package/dist/resources/handlers.d.ts +1 -1
  30. package/dist/server/agent-engine-api-key-route.d.ts +7 -0
  31. package/dist/server/agent-engine-api-key-route.js +38 -0
  32. package/dist/server/realtime-token.d.ts +1 -1
  33. package/package.json +1 -1
@@ -211,9 +211,11 @@ struct NativeUploadResumeResponse {
211
211
  next_chunk_index: Option<u64>,
212
212
  attempt_id: Option<String>,
213
213
  upload_generation_id: Option<String>,
214
+ reason: Option<String>,
215
+ retry_after_ms: Option<u64>,
214
216
  }
215
217
 
216
- #[derive(Debug, Deserialize)]
218
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
217
219
  #[serde(rename_all = "camelCase")]
218
220
  struct NativeUploadResetResponse {
219
221
  upload_mode: Option<String>,
@@ -226,6 +228,16 @@ impl NativeUploadResetResponse {
226
228
  }
227
229
  }
228
230
 
231
+ fn accept_native_retry_reset(
232
+ reset: NativeUploadResetResponse,
233
+ cancelled: bool,
234
+ ) -> Result<NativeUploadResetResponse, String> {
235
+ if cancelled {
236
+ return Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string());
237
+ }
238
+ Ok(reset)
239
+ }
240
+
229
241
  impl NativeUploadMode {
230
242
  pub(crate) fn from_option(value: Option<String>) -> Self {
231
243
  match value.as_deref() {
@@ -956,6 +968,8 @@ fn clear_recording_active(app: &AppHandle) {
956
968
  static LAST_NATIVE_UPLOAD_FINISHED: OnceLock<Mutex<Option<NativeUploadFinishedPayload>>> =
957
969
  OnceLock::new();
958
970
  static CLAIMED_NATIVE_UPLOAD_OPEN: OnceLock<Mutex<Option<String>>> = OnceLock::new();
971
+ static CANCELLED_NATIVE_UPLOAD_RETRIES: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
972
+ const NATIVE_UPLOAD_RETRY_CANCELLED: &str = "native recording upload retry cancelled";
959
973
 
960
974
  fn last_native_upload_finished() -> &'static Mutex<Option<NativeUploadFinishedPayload>> {
961
975
  LAST_NATIVE_UPLOAD_FINISHED.get_or_init(|| Mutex::new(None))
@@ -965,6 +979,36 @@ fn claimed_native_upload_open() -> &'static Mutex<Option<String>> {
965
979
  CLAIMED_NATIVE_UPLOAD_OPEN.get_or_init(|| Mutex::new(None))
966
980
  }
967
981
 
982
+ fn cancelled_native_upload_retries() -> &'static Mutex<BTreeSet<String>> {
983
+ CANCELLED_NATIVE_UPLOAD_RETRIES.get_or_init(|| Mutex::new(BTreeSet::new()))
984
+ }
985
+
986
+ fn native_upload_retry_cancelled(recording_id: &str) -> bool {
987
+ cancelled_native_upload_retries()
988
+ .lock()
989
+ .map(|cancelled| cancelled.contains(recording_id))
990
+ .unwrap_or(true)
991
+ }
992
+
993
+ fn clear_native_upload_retry_cancelled(recording_id: &str) {
994
+ if let Ok(mut cancelled) = cancelled_native_upload_retries().lock() {
995
+ cancelled.remove(recording_id);
996
+ }
997
+ }
998
+
999
+ fn take_native_upload_retry_cancelled(recording_id: &str) -> bool {
1000
+ cancelled_native_upload_retries()
1001
+ .lock()
1002
+ .map(|mut cancelled| cancelled.remove(recording_id))
1003
+ .unwrap_or(true)
1004
+ }
1005
+
1006
+ async fn wait_for_native_upload_retry_cancel(recording_id: &str) {
1007
+ while !native_upload_retry_cancelled(recording_id) {
1008
+ tokio::time::sleep(Duration::from_millis(100)).await;
1009
+ }
1010
+ }
1011
+
968
1012
  fn reset_native_upload_completion_state() {
969
1013
  if let Ok(mut last) = last_native_upload_finished().lock() {
970
1014
  *last = None;
@@ -2130,6 +2174,7 @@ pub async fn native_fullscreen_recording_stop_and_upload(
2130
2174
 
2131
2175
  match result {
2132
2176
  Ok(result) => {
2177
+ clear_native_upload_retry_cancelled(&recording_id);
2133
2178
  if !result.verification_pending {
2134
2179
  clear_saved_recording_after_success(&app, &saved);
2135
2180
  }
@@ -3636,6 +3681,10 @@ pub async fn native_fullscreen_recording_retry_upload(
3636
3681
  auth_token: Option<String>,
3637
3682
  cookie: Option<String>,
3638
3683
  ) -> Result<NativeFullscreenUploadResult, String> {
3684
+ if take_native_upload_retry_cancelled(&recording_id) {
3685
+ emit_native_upload_progress(&app, "paused", "Retry cancelled", None, None);
3686
+ return Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string());
3687
+ }
3639
3688
  let mut saved = read_saved_recording_metadata(&app, &recording_id)?;
3640
3689
  saved.server_url = server_url.trim_end_matches('/').to_string();
3641
3690
  saved.last_attempt_at = Some(now_iso());
@@ -3658,6 +3707,7 @@ pub async fn native_fullscreen_recording_retry_upload(
3658
3707
  // second click cannot steal an upload session already owned by this
3659
3708
  // local recording.
3660
3709
  let retry_plan = match get_native_retry_upload_plan(
3710
+ &app,
3661
3711
  &saved.server_url,
3662
3712
  &saved.recording_id,
3663
3713
  prepared.bytes,
@@ -3670,16 +3720,18 @@ pub async fn native_fullscreen_recording_retry_upload(
3670
3720
  {
3671
3721
  Ok(plan) => plan,
3672
3722
  Err(err) => {
3673
- interrupt_native_retry_upload(
3674
- &saved.server_url,
3675
- &saved.recording_id,
3676
- &err,
3677
- Some(&claimed_attempt_id),
3678
- None,
3679
- &auth_token,
3680
- &cookie,
3681
- )
3682
- .await;
3723
+ if err != NATIVE_UPLOAD_RETRY_CANCELLED {
3724
+ interrupt_native_retry_upload(
3725
+ &saved.server_url,
3726
+ &saved.recording_id,
3727
+ &err,
3728
+ Some(&claimed_attempt_id),
3729
+ None,
3730
+ &auth_token,
3731
+ &cookie,
3732
+ )
3733
+ .await;
3734
+ }
3683
3735
  cleanup_prepared_saved_recording_files(&prepared, retry_combined_path);
3684
3736
  return Err(err);
3685
3737
  }
@@ -3753,19 +3805,25 @@ pub async fn native_fullscreen_recording_retry_upload(
3753
3805
  {
3754
3806
  Ok(reset) => reset,
3755
3807
  Err(err) => {
3756
- interrupt_native_retry_upload(
3757
- &saved.server_url,
3758
- &saved.recording_id,
3759
- &err,
3760
- active_attempt_id.as_deref(),
3761
- active_upload_generation_id.as_deref(),
3762
- &auth_token,
3763
- &cookie,
3764
- )
3765
- .await;
3808
+ if err != NATIVE_UPLOAD_RETRY_CANCELLED {
3809
+ interrupt_native_retry_upload(
3810
+ &saved.server_url,
3811
+ &saved.recording_id,
3812
+ &err,
3813
+ active_attempt_id.as_deref(),
3814
+ active_upload_generation_id.as_deref(),
3815
+ &auth_token,
3816
+ &cookie,
3817
+ )
3818
+ .await;
3819
+ }
3766
3820
  return Err(err);
3767
3821
  }
3768
3822
  };
3823
+ let reset = accept_native_retry_reset(
3824
+ reset,
3825
+ native_upload_retry_cancelled(&saved.recording_id),
3826
+ )?;
3769
3827
  (reset.mode(), None, reset.upload_generation_id)
3770
3828
  }
3771
3829
  NativeRetryUploadPlan::Reconcile => unreachable!("handled above"),
@@ -3814,6 +3872,10 @@ pub async fn native_fullscreen_recording_retry_upload(
3814
3872
  {
3815
3873
  Ok(reset) => {
3816
3874
  interruption_upload_generation_id = reset.upload_generation_id.clone();
3875
+ let reset = accept_native_retry_reset(
3876
+ reset,
3877
+ native_upload_retry_cancelled(&saved.recording_id),
3878
+ )?;
3817
3879
  upload_prepared_recording_file(
3818
3880
  &app,
3819
3881
  &prepared,
@@ -3839,16 +3901,18 @@ pub async fn native_fullscreen_recording_retry_upload(
3839
3901
  upload_result
3840
3902
  };
3841
3903
  if let Err(err) = &upload_result {
3842
- interrupt_native_retry_upload(
3843
- &saved.server_url,
3844
- &saved.recording_id,
3845
- err,
3846
- replay_attempt_id.as_deref(),
3847
- interruption_upload_generation_id.as_deref(),
3848
- &auth_token,
3849
- &cookie,
3850
- )
3851
- .await;
3904
+ if err != NATIVE_UPLOAD_RETRY_CANCELLED {
3905
+ interrupt_native_retry_upload(
3906
+ &saved.server_url,
3907
+ &saved.recording_id,
3908
+ err,
3909
+ replay_attempt_id.as_deref(),
3910
+ interruption_upload_generation_id.as_deref(),
3911
+ &auth_token,
3912
+ &cookie,
3913
+ )
3914
+ .await;
3915
+ }
3852
3916
  }
3853
3917
  cleanup_prepared_saved_recording_files(&prepared, retry_combined_path);
3854
3918
  upload_result
@@ -3863,6 +3927,11 @@ pub async fn native_fullscreen_recording_retry_upload(
3863
3927
  Ok(result)
3864
3928
  }
3865
3929
  Err(err) => {
3930
+ clear_native_upload_retry_cancelled(&recording_id);
3931
+ if err == NATIVE_UPLOAD_RETRY_CANCELLED {
3932
+ emit_native_upload_progress(&app, "paused", "Retry cancelled", None, None);
3933
+ return Err(err);
3934
+ }
3866
3935
  if is_moov_corrupt_error(&err) {
3867
3936
  saved.corrupt = true;
3868
3937
  }
@@ -3878,6 +3947,15 @@ pub async fn native_fullscreen_recording_retry_upload(
3878
3947
  }
3879
3948
  }
3880
3949
 
3950
+ #[tauri::command]
3951
+ pub fn native_fullscreen_recording_cancel_retry(recording_id: String) -> Result<(), String> {
3952
+ cancelled_native_upload_retries()
3953
+ .lock()
3954
+ .map_err(|_| "native upload retry cancellation state is unavailable".to_string())?
3955
+ .insert(recording_id);
3956
+ Ok(())
3957
+ }
3958
+
3881
3959
  #[tauri::command]
3882
3960
  pub async fn native_fullscreen_recording_mark_upload_error(
3883
3961
  app: AppHandle,
@@ -5554,7 +5632,8 @@ async fn upload_prepared_recording_file(
5554
5632
  let mut buffer = vec![0_u8; UPLOAD_CHUNK_BYTES];
5555
5633
  file.read_exact(&mut buffer)
5556
5634
  .map_err(|e| format!("native recording read failed: {e}"))?;
5557
- send_upload_post_with_attempt(
5635
+ tokio::select! {
5636
+ result = send_upload_post_with_attempt(
5558
5637
  &client,
5559
5638
  &server_url,
5560
5639
  &recording_id,
@@ -5575,8 +5654,11 @@ async fn upload_prepared_recording_file(
5575
5654
  upload_attempt_id,
5576
5655
  upload_generation_id,
5577
5656
  buffer,
5578
- )
5579
- .await?;
5657
+ ) => result,
5658
+ _ = wait_for_native_upload_retry_cancel(&recording_id) => {
5659
+ Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string())
5660
+ }
5661
+ }?;
5580
5662
  emit_native_upload_progress(
5581
5663
  app,
5582
5664
  "uploading",
@@ -5599,7 +5681,8 @@ async fn upload_prepared_recording_file(
5599
5681
  None,
5600
5682
  Some(streaming_full_chunks as f32 / total_posts as f32),
5601
5683
  );
5602
- verification_pending = send_upload_post_with_attempt(
5684
+ verification_pending = tokio::select! {
5685
+ result = send_upload_post_with_attempt(
5603
5686
  &client,
5604
5687
  &server_url,
5605
5688
  &recording_id,
@@ -5620,8 +5703,11 @@ async fn upload_prepared_recording_file(
5620
5703
  upload_attempt_id,
5621
5704
  upload_generation_id,
5622
5705
  final_body,
5623
- )
5624
- .await?;
5706
+ ) => result,
5707
+ _ = wait_for_native_upload_retry_cancel(&recording_id) => {
5708
+ Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string())
5709
+ }
5710
+ }?;
5625
5711
  } else {
5626
5712
  for index in 0..total_chunks {
5627
5713
  let mut buffer = vec![0_u8; UPLOAD_CHUNK_BYTES];
@@ -5632,7 +5718,8 @@ async fn upload_prepared_recording_file(
5632
5718
  return Err("Native recording ended before all chunks were read.".into());
5633
5719
  }
5634
5720
  buffer.truncate(read);
5635
- send_upload_post_with_attempt(
5721
+ tokio::select! {
5722
+ result = send_upload_post_with_attempt(
5636
5723
  &client,
5637
5724
  &server_url,
5638
5725
  &recording_id,
@@ -5653,8 +5740,11 @@ async fn upload_prepared_recording_file(
5653
5740
  upload_attempt_id,
5654
5741
  upload_generation_id,
5655
5742
  buffer,
5656
- )
5657
- .await?;
5743
+ ) => result,
5744
+ _ = wait_for_native_upload_retry_cancel(&recording_id) => {
5745
+ Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string())
5746
+ }
5747
+ }?;
5658
5748
  emit_native_upload_progress(
5659
5749
  app,
5660
5750
  "uploading",
@@ -5671,7 +5761,8 @@ async fn upload_prepared_recording_file(
5671
5761
  None,
5672
5762
  Some(total_chunks as f32 / total_posts as f32),
5673
5763
  );
5674
- verification_pending = send_upload_post_with_attempt(
5764
+ verification_pending = tokio::select! {
5765
+ result = send_upload_post_with_attempt(
5675
5766
  &client,
5676
5767
  &server_url,
5677
5768
  &recording_id,
@@ -5692,8 +5783,11 @@ async fn upload_prepared_recording_file(
5692
5783
  upload_attempt_id,
5693
5784
  upload_generation_id,
5694
5785
  Vec::new(),
5695
- )
5696
- .await?;
5786
+ ) => result,
5787
+ _ = wait_for_native_upload_retry_cancel(&recording_id) => {
5788
+ Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string())
5789
+ }
5790
+ }?;
5697
5791
  }
5698
5792
 
5699
5793
  emit_native_upload_progress(app, "opening", "Uploading clip", None, Some(1.0));
@@ -5708,6 +5802,7 @@ async fn upload_prepared_recording_file(
5708
5802
  }
5709
5803
 
5710
5804
  async fn get_native_retry_upload_plan(
5805
+ app: &AppHandle,
5711
5806
  server_url: &str,
5712
5807
  recording_id: &str,
5713
5808
  local_bytes: u64,
@@ -5735,30 +5830,104 @@ async fn get_native_retry_upload_plan(
5735
5830
  if !cookie.trim().is_empty() {
5736
5831
  request = request.header("Cookie", cookie.trim());
5737
5832
  }
5738
- let response = request
5739
- .send()
5740
- .await
5741
- .map_err(|e| format!("native recording resume check failed: {e}"))?;
5742
- let status = response.status();
5743
- let body = response.text().await.unwrap_or_default();
5744
- if !status.is_success() {
5745
- return Err(format!(
5746
- "native recording resume check returned {status}: {}",
5747
- body.chars().take(400).collect::<String>()
5833
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(5 * 60);
5834
+ loop {
5835
+ if native_upload_retry_cancelled(recording_id) {
5836
+ return Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string());
5837
+ }
5838
+ let response = tokio::select! {
5839
+ response = request
5840
+ .try_clone()
5841
+ .ok_or_else(|| "native recording resume request could not be retried".to_string())?
5842
+ .send() => response.map_err(|e| format!("native recording resume check failed: {e}"))?,
5843
+ _ = wait_for_native_upload_retry_cancel(recording_id) => {
5844
+ return Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string());
5845
+ }
5846
+ };
5847
+ let status = response.status();
5848
+ let body = response.text().await.unwrap_or_default();
5849
+ let parsed = serde_json::from_str::<NativeUploadResumeResponse>(&body);
5850
+ if !status.is_success() {
5851
+ if let Ok(conflict) = &parsed {
5852
+ if let Some(delay) = native_retry_conflict_delay(conflict) {
5853
+ if tokio::time::Instant::now() + delay <= deadline {
5854
+ emit_native_upload_progress(
5855
+ app,
5856
+ "uploading",
5857
+ "Waiting for prior retry",
5858
+ None,
5859
+ None,
5860
+ );
5861
+ tokio::select! {
5862
+ _ = tokio::time::sleep(delay) => {}
5863
+ _ = wait_for_native_upload_retry_cancel(recording_id) => {
5864
+ return Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string());
5865
+ }
5866
+ }
5867
+ continue;
5868
+ }
5869
+ return Err(
5870
+ "Another upload retry is still active. Wait a moment and try again"
5871
+ .to_string(),
5872
+ );
5873
+ }
5874
+ if conflict.reason.as_deref() == Some("retry_claim_liveness_unavailable") {
5875
+ return Err(
5876
+ "Clips could not verify whether another retry is active".to_string()
5877
+ );
5878
+ }
5879
+ }
5880
+ return Err(format!("native recording resume check failed ({status})"));
5881
+ }
5882
+ let response = parsed.map_err(|_| {
5883
+ "native recording resume check returned an unreadable response".to_string()
5884
+ })?;
5885
+ if response.resumable && response.attempt_id.as_deref() != Some(claimed_attempt_id) {
5886
+ return Err(
5887
+ "native recording resume check did not acknowledge its attempt claim".to_string(),
5888
+ );
5889
+ }
5890
+ let recovery_enabled = response.recovery_enabled;
5891
+ let rollback_attempt_id = response.attempt_id.clone();
5892
+ let rollback_generation_id = response.upload_generation_id.clone();
5893
+ return Ok(preserve_native_retry_fence_during_rollback(
5894
+ plan_native_retry_upload(response, local_bytes, exact_local_stream),
5895
+ recovery_enabled,
5896
+ rollback_attempt_id,
5897
+ rollback_generation_id,
5748
5898
  ));
5749
5899
  }
5750
- let response: NativeUploadResumeResponse = serde_json::from_str(&body)
5751
- .map_err(|_| "native recording resume check returned an unreadable response".to_string())?;
5752
- if response.resumable && response.attempt_id.as_deref() != Some(claimed_attempt_id) {
5753
- return Err(
5754
- "native recording resume check did not acknowledge its attempt claim".to_string(),
5755
- );
5900
+ }
5901
+
5902
+ fn preserve_native_retry_fence_during_rollback(
5903
+ mut plan: NativeRetryUploadPlan,
5904
+ recovery_enabled: bool,
5905
+ acknowledged_attempt_id: Option<String>,
5906
+ upload_generation_id: Option<String>,
5907
+ ) -> NativeRetryUploadPlan {
5908
+ if !recovery_enabled {
5909
+ if let NativeRetryUploadPlan::Restart {
5910
+ attempt_id,
5911
+ upload_generation_id: planned_generation_id,
5912
+ } = &mut plan
5913
+ {
5914
+ *attempt_id = acknowledged_attempt_id;
5915
+ *planned_generation_id = upload_generation_id;
5916
+ }
5756
5917
  }
5757
- Ok(plan_native_retry_upload(
5758
- response,
5759
- local_bytes,
5760
- exact_local_stream,
5761
- ))
5918
+ plan
5919
+ }
5920
+
5921
+ fn native_retry_conflict_delay(response: &NativeUploadResumeResponse) -> Option<Duration> {
5922
+ if response.resumable
5923
+ || !response.recovery_enabled
5924
+ || response.reason.as_deref() != Some("retry_already_active")
5925
+ {
5926
+ return None;
5927
+ }
5928
+ response
5929
+ .retry_after_ms
5930
+ .map(|delay| Duration::from_millis(delay.clamp(250, 30_000)))
5762
5931
  }
5763
5932
 
5764
5933
  #[derive(Deserialize)]
@@ -5944,14 +6113,55 @@ fn native_retry_interruption_payload(
5944
6113
  #[cfg(test)]
5945
6114
  mod native_retry_upload_plan_tests {
5946
6115
  use super::{
5947
- is_native_upload_restart_required, is_native_upload_unfenced_restart_required,
5948
- native_replay_attempt_id, native_retry_attempt_id, native_retry_interruption_payload,
5949
- plan_native_retry_upload, saved_native_retry_attempt_id, upload_url,
5950
- NativeFullscreenUploadResult, NativeRetryUploadPlan, NativeUploadResumeResponse,
5951
- NATIVE_UPLOAD_RESTART_REQUIRED, NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED,
5952
- UPLOAD_CHUNK_BYTES,
6116
+ accept_native_retry_reset, is_native_upload_restart_required,
6117
+ is_native_upload_unfenced_restart_required, native_fullscreen_recording_cancel_retry,
6118
+ native_replay_attempt_id, native_retry_attempt_id, native_retry_conflict_delay,
6119
+ native_retry_interruption_payload, native_upload_retry_cancelled, plan_native_retry_upload,
6120
+ preserve_native_retry_fence_during_rollback, saved_native_retry_attempt_id,
6121
+ take_native_upload_retry_cancelled, upload_url, NativeFullscreenUploadResult,
6122
+ NativeRetryUploadPlan, NativeUploadResetResponse, NativeUploadResumeResponse,
6123
+ NATIVE_UPLOAD_RESTART_REQUIRED, NATIVE_UPLOAD_RETRY_CANCELLED,
6124
+ NATIVE_UPLOAD_UNFENCED_RESTART_REQUIRED, UPLOAD_CHUNK_BYTES,
5953
6125
  };
5954
6126
 
6127
+ #[test]
6128
+ fn consumes_a_cancellation_that_arrives_before_retry_startup() {
6129
+ let recording_id = "pre-start-cancel-recording".to_string();
6130
+ assert!(!take_native_upload_retry_cancelled(&recording_id));
6131
+
6132
+ native_fullscreen_recording_cancel_retry(recording_id.clone())
6133
+ .expect("record pre-start cancellation");
6134
+ assert!(native_upload_retry_cancelled(&recording_id));
6135
+ assert!(take_native_upload_retry_cancelled(&recording_id));
6136
+ assert!(!native_upload_retry_cancelled(&recording_id));
6137
+ }
6138
+
6139
+ #[test]
6140
+ fn cancellation_after_a_committed_reset_preserves_the_authoritative_response() {
6141
+ let reset = NativeUploadResetResponse {
6142
+ upload_mode: Some("streaming".to_string()),
6143
+ upload_generation_id: Some("generation-after-reset".to_string()),
6144
+ };
6145
+
6146
+ assert_eq!(
6147
+ accept_native_retry_reset(reset, true),
6148
+ Err(NATIVE_UPLOAD_RETRY_CANCELLED.to_string())
6149
+ );
6150
+ assert_eq!(
6151
+ accept_native_retry_reset(
6152
+ NativeUploadResetResponse {
6153
+ upload_mode: Some("streaming".to_string()),
6154
+ upload_generation_id: Some("generation-after-reset".to_string()),
6155
+ },
6156
+ false,
6157
+ )
6158
+ .expect("retry may re-enter the committed reset fence")
6159
+ .upload_generation_id
6160
+ .as_deref(),
6161
+ Some("generation-after-reset")
6162
+ );
6163
+ }
6164
+
5955
6165
  fn response(bytes_received: u64, next_chunk_index: u64) -> NativeUploadResumeResponse {
5956
6166
  NativeUploadResumeResponse {
5957
6167
  resumable: true,
@@ -5962,6 +6172,8 @@ mod native_retry_upload_plan_tests {
5962
6172
  next_chunk_index: Some(next_chunk_index),
5963
6173
  attempt_id: Some("attempt-1".to_string()),
5964
6174
  upload_generation_id: Some("generation-1".to_string()),
6175
+ reason: None,
6176
+ retry_after_ms: None,
5965
6177
  }
5966
6178
  }
5967
6179
 
@@ -6013,6 +6225,8 @@ mod native_retry_upload_plan_tests {
6013
6225
  next_chunk_index: None,
6014
6226
  attempt_id: Some("ignored-attempt".to_string()),
6015
6227
  upload_generation_id: Some("ignored-generation".to_string()),
6228
+ reason: Some("feature_disabled".to_string()),
6229
+ retry_after_ms: None,
6016
6230
  },
6017
6231
  UPLOAD_CHUNK_BYTES as u64,
6018
6232
  true,
@@ -6027,6 +6241,66 @@ mod native_retry_upload_plan_tests {
6027
6241
  ));
6028
6242
  }
6029
6243
 
6244
+ #[test]
6245
+ fn preserves_an_existing_fence_when_resumable_retry_is_disabled() {
6246
+ let plan = preserve_native_retry_fence_during_rollback(
6247
+ NativeRetryUploadPlan::Restart {
6248
+ attempt_id: None,
6249
+ upload_generation_id: None,
6250
+ },
6251
+ false,
6252
+ Some("attempt-1".to_string()),
6253
+ Some("generation-1".to_string()),
6254
+ );
6255
+ assert!(matches!(
6256
+ plan,
6257
+ NativeRetryUploadPlan::Restart {
6258
+ attempt_id: Some(attempt_id),
6259
+ upload_generation_id: Some(generation_id),
6260
+ } if attempt_id == "attempt-1" && generation_id == "generation-1"
6261
+ ));
6262
+ }
6263
+
6264
+ #[test]
6265
+ fn keeps_an_unacknowledged_legacy_restart_unfenced_when_resumable_retry_is_disabled() {
6266
+ let plan = preserve_native_retry_fence_during_rollback(
6267
+ NativeRetryUploadPlan::Restart {
6268
+ attempt_id: None,
6269
+ upload_generation_id: None,
6270
+ },
6271
+ false,
6272
+ None,
6273
+ None,
6274
+ );
6275
+ assert!(matches!(
6276
+ plan,
6277
+ NativeRetryUploadPlan::Restart {
6278
+ attempt_id: None,
6279
+ upload_generation_id: None,
6280
+ }
6281
+ ));
6282
+ }
6283
+
6284
+ #[test]
6285
+ fn preserves_an_acknowledged_legacy_attempt_without_a_generation() {
6286
+ let plan = preserve_native_retry_fence_during_rollback(
6287
+ NativeRetryUploadPlan::Restart {
6288
+ attempt_id: None,
6289
+ upload_generation_id: None,
6290
+ },
6291
+ false,
6292
+ Some("attempt-1".to_string()),
6293
+ None,
6294
+ );
6295
+ assert!(matches!(
6296
+ plan,
6297
+ NativeRetryUploadPlan::Restart {
6298
+ attempt_id: Some(attempt_id),
6299
+ upload_generation_id: None,
6300
+ } if attempt_id == "attempt-1"
6301
+ ));
6302
+ }
6303
+
6030
6304
  #[test]
6031
6305
  fn reconciles_terminal_resume_without_an_attempt_echo() {
6032
6306
  let terminal = plan_native_retry_upload(
@@ -6039,6 +6313,8 @@ mod native_retry_upload_plan_tests {
6039
6313
  next_chunk_index: None,
6040
6314
  attempt_id: None,
6041
6315
  upload_generation_id: None,
6316
+ reason: None,
6317
+ retry_after_ms: None,
6042
6318
  },
6043
6319
  UPLOAD_CHUNK_BYTES as u64,
6044
6320
  true,
@@ -6064,6 +6340,8 @@ mod native_retry_upload_plan_tests {
6064
6340
  next_chunk_index: Some(0),
6065
6341
  attempt_id: Some(claimed_attempt_id.clone()),
6066
6342
  upload_generation_id: Some("generation-1".to_string()),
6343
+ reason: None,
6344
+ retry_after_ms: None,
6067
6345
  },
6068
6346
  UPLOAD_CHUNK_BYTES as u64,
6069
6347
  true,
@@ -6108,6 +6386,30 @@ mod native_retry_upload_plan_tests {
6108
6386
  assert_eq!(saved_attempt_id.as_deref(), Some(first.as_str()));
6109
6387
  }
6110
6388
 
6389
+ #[test]
6390
+ fn waits_only_for_a_typed_bounded_retry_conflict() {
6391
+ let conflict = NativeUploadResumeResponse {
6392
+ resumable: false,
6393
+ recovery_enabled: true,
6394
+ status: Some("uploading".to_string()),
6395
+ upload_mode: None,
6396
+ bytes_received: None,
6397
+ next_chunk_index: None,
6398
+ attempt_id: None,
6399
+ upload_generation_id: None,
6400
+ reason: Some("retry_already_active".to_string()),
6401
+ retry_after_ms: Some(60_000),
6402
+ };
6403
+ assert_eq!(
6404
+ native_retry_conflict_delay(&conflict),
6405
+ Some(std::time::Duration::from_secs(30))
6406
+ );
6407
+
6408
+ let mut untyped = conflict;
6409
+ untyped.retry_after_ms = None;
6410
+ assert_eq!(native_retry_conflict_delay(&untyped), None);
6411
+ }
6412
+
6111
6413
  #[test]
6112
6414
  fn reports_retry_interruptions_with_or_without_a_fencing_claim() {
6113
6415
  let unfenced = native_retry_interruption_payload("upload failed", None, None);
@@ -1,4 +1,5 @@
1
1
  import {
2
+ compareAndSetAppState,
2
3
  deleteAppState,
3
4
  readAppState,
4
5
  writeAppState,
@@ -10,6 +11,20 @@ export interface StoredResumableSession {
10
11
  meta: Record<string, unknown>;
11
12
  bytesUploaded: number;
12
13
  lastCommittedIndex?: number;
14
+ providerClosed?: boolean;
15
+ }
16
+
17
+ export async function compareAndSetResumableSession(
18
+ recordingId: string,
19
+ expected: StoredResumableSession,
20
+ next: StoredResumableSession,
21
+ generationId?: string | null,
22
+ ): Promise<boolean> {
23
+ return compareAndSetAppState(
24
+ key(recordingId, generationId),
25
+ expected as unknown as Record<string, unknown>,
26
+ next as unknown as Record<string, unknown>,
27
+ );
13
28
  }
14
29
 
15
30
  const key = (recordingId: string, generationId?: string | null) =>