@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.247

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 (44) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +70 -0
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +97 -0
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/AppRegistry.d.ts +2 -12
  8. package/build/services/AppRegistry.d.ts.map +1 -1
  9. package/build/services/AppRegistry.js +2 -37
  10. package/build/services/AppRegistry.js.map +1 -1
  11. package/build/services/CloudClientService.d.ts +1 -1
  12. package/build/services/CloudClientService.d.ts.map +1 -1
  13. package/build/services/CloudClientService.js +2 -2
  14. package/build/services/CloudClientService.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +51 -0
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +228 -6
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/PhoneNotificationsSync.d.ts +1 -1
  20. package/build/services/PhonePhotoCoordinator.d.ts +3 -2
  21. package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
  22. package/build/services/PhonePhotoCoordinator.js +19 -14
  23. package/build/services/PhonePhotoCoordinator.js.map +1 -1
  24. package/build/services/SoftapCallTransport.d.ts +64 -0
  25. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  26. package/build/services/SoftapCallTransport.js +103 -3
  27. package/build/services/SoftapCallTransport.js.map +1 -1
  28. package/build/services/manifestPermissions.d.ts +13 -0
  29. package/build/services/manifestPermissions.d.ts.map +1 -0
  30. package/build/services/manifestPermissions.js +42 -0
  31. package/build/services/manifestPermissions.js.map +1 -0
  32. package/build/types/applet.d.ts +13 -1
  33. package/build/types/applet.d.ts.map +1 -1
  34. package/build/types/applet.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/generated/releaseMetadata.ts +5 -5
  37. package/src/services/AcsMeetingService.ts +138 -0
  38. package/src/services/AppRegistry.ts +3 -40
  39. package/src/services/CloudClientService.ts +2 -2
  40. package/src/services/LocalMiniappRuntime.ts +268 -7
  41. package/src/services/PhonePhotoCoordinator.ts +18 -14
  42. package/src/services/SoftapCallTransport.ts +151 -16
  43. package/src/services/manifestPermissions.ts +44 -0
  44. package/src/types/applet.ts +11 -0
@@ -8,6 +8,7 @@
8
8
  * This runtime handles request dispatch, stream fan-out, and lifecycle
9
9
  * management (connect/disconnect/ping).
10
10
  */
11
+ import { parsePhotoCompression } from "@mentra/cloud-protocol/photo-compression";
11
12
  import { acquireGlassesHotspot } from "./GlassesHotspotLease";
12
13
  import { AppState, Linking } from "react-native";
13
14
  import Share from "react-native-share";
@@ -58,7 +59,7 @@ import { resolveForegroundLocationPermission } from "./ForegroundLocationPermiss
58
59
  import { advanceMiniappPingLiveness } from "./MiniappLiveness";
59
60
  import { listPhoneCalendarEvents, PhoneCalendarError } from "./PhoneCalendarService";
60
61
  import { LocalMiniappStorage } from "./LocalMiniappStorage";
61
- import acsMeetingService, { parseAcsOutgoingVideo, parseAcsVideoSource, resolveAcsAudioSource, } from "./AcsMeetingService";
62
+ import acsMeetingService, { parseAcsCallOrigin, parseAcsOutgoingVideo, parseAcsVideoSource, resolveAcsAudioSource, } from "./AcsMeetingService";
62
63
  import { createSoftapCallDeps, SoftapCallError, SoftapCallTransport, SOFTAP_STEPS, } from "./SoftapCallTransport";
63
64
  import { awaitCleanupBarrier } from "./SoftapCleanupBarrier";
64
65
  import { softapTrace, softapTraceFailure } from "../utils/softapTrace";
@@ -253,6 +254,30 @@ function resetPermissionWarnings(packageName) {
253
254
  }
254
255
  /** How long a stalled SoftAP cleanup goes unremarked before it is logged. It is never abandoned. */
255
256
  const SOFTAP_CLEANUP_STALL_LOG_MS = 10_000;
257
+ /**
258
+ * The WHIP listener's tombstone (3s) plus a margin for the close itself.
259
+ *
260
+ * Sized to the grace period rather than picked round, so an expiry here means something actually
261
+ * went wrong instead of meaning we guessed short.
262
+ */
263
+ const SOFTAP_INGEST_CLOSE_WAIT_MS = 3_500;
264
+ /** How long the glasses get to acknowledge their hotspot off, over BLE. */
265
+ const SOFTAP_HOTSPOT_OFF_ACK_MS = 5_000;
266
+ /** After a forced close there is nothing left to wait for, so this only catches a real failure. */
267
+ const SOFTAP_FORCED_VERIFY_MS = 500;
268
+ /** Reject with [reason] if [work] has not settled in [ms]. The work itself is not cancellable. */
269
+ function withTimeout(work, ms, reason) {
270
+ return new Promise((resolve, reject) => {
271
+ const timer = setTimeout(() => reject(new Error(reason)), ms);
272
+ work.then((value) => {
273
+ clearTimeout(timer);
274
+ resolve(value);
275
+ }, (error) => {
276
+ clearTimeout(timer);
277
+ reject(error);
278
+ });
279
+ });
280
+ }
256
281
  /** How long the next call waits for the previous one's cleanup before telling the wearer about it. */
257
282
  const SOFTAP_CLEANUP_NARRATE_AFTER_MS = 250;
258
283
  // =============================================================================
@@ -269,6 +294,16 @@ class LocalMiniappRuntime {
269
294
  * before the first `await` and is what every later stage checks itself against.
270
295
  */
271
296
  softapAttempt = null;
297
+ /**
298
+ * The outer generation. Every async callback that can outlive an attempt checks itself against
299
+ * it and traces `softap_stale_callback` when it loses: ACS state, SoftAP progress, glasses
300
+ * stream status, scoped-network loss, and each checkpoint through the join.
301
+ *
302
+ * There are inner generations below this one — `AcsMeetingSession.joinGeneration` and
303
+ * `LocalWhipIngestSource.generation` — and they are not redundant. They fence work inside one
304
+ * native session against itself; this one fences whole attempts against each other, and only it
305
+ * knows that the wearer pressed Cancel before any of that native work existed.
306
+ */
272
307
  softapAttemptSeq = 0;
273
308
  /**
274
309
  * Why the previous SoftAP call's cleanup failed, if it did.
@@ -277,6 +312,13 @@ class LocalMiniappRuntime {
277
312
  * would not turn off is precisely what makes the next call fail somewhere far less legible.
278
313
  */
279
314
  softapCleanupError = null;
315
+ /**
316
+ * SoftAP teardown disables the glasses hotspot from the transport and again
317
+ * from the host barrier. Native used to throw `request_in_flight` on the
318
+ * second call, which was recorded as a cleanup failure and refused the next
319
+ * join. One queue so those overlapping disables wait their turn.
320
+ */
321
+ glassesHotspotCommand = Promise.resolve();
280
322
  /** Connected miniapps keyed by packageName. */
281
323
  connectedApps = new Map();
282
324
  /**
@@ -2886,6 +2928,26 @@ class LocalMiniappRuntime {
2886
2928
  });
2887
2929
  return false;
2888
2930
  }
2931
+ /**
2932
+ * OS-permission gate: checked, never requested.
2933
+ *
2934
+ * The prompt belongs at miniapp-open time, where it can be explained. Requesting here would put
2935
+ * a system dialog on top of a join that has already started building a hotspot, and a wearer who
2936
+ * denies it gets a call that half-exists. Missing means the join is refused with the permission
2937
+ * named, so the miniapp can send them to Settings.
2938
+ */
2939
+ async requireOsPermission(packageName, requestId, feature, label) {
2940
+ if (await permissions.check(feature))
2941
+ return true;
2942
+ softapTraceFailure("softap_permission_missing", { packageName, feature });
2943
+ this.sendResult(packageName, requestId, false, undefined, {
2944
+ code: MiniappErrorCode.PERMISSION_DENIED,
2945
+ message: `This phone's ${label} permission is not granted. Grant it in Settings, then try again.`,
2946
+ permission: feature,
2947
+ operation: MiniappRequestType.MEETING_JOIN,
2948
+ });
2949
+ return false;
2950
+ }
2889
2951
  async handlePhoto(packageName, payload, requestId) {
2890
2952
  // Manifest CAMERA permission gate.
2891
2953
  const app = this.connectedApps.get(packageName);
@@ -2905,7 +2967,7 @@ class LocalMiniappRuntime {
2905
2967
  size: payload.size,
2906
2968
  mode: payload.mode,
2907
2969
  transferMethod: payload.transferMethod,
2908
- compress: payload.compress,
2970
+ compress: parsePhotoCompression(payload.compress),
2909
2971
  sound: payload.sound,
2910
2972
  saveToGallery: payload.saveToGallery,
2911
2973
  exposureTimeNs: payload.exposureTimeNs,
@@ -3136,12 +3198,24 @@ class LocalMiniappRuntime {
3136
3198
  // A meeting puts the glasses camera and mic in front of remote strangers; it
3137
3199
  // needs both declared, same as photo/stream and mic capture do individually.
3138
3200
  if (!this.requireManifestPermission(packageName, requestId, "CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
3139
- !this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN)) {
3201
+ !this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
3202
+ !this.requireManifestPermission(packageName, requestId, "PHONE_CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN)) {
3140
3203
  return;
3141
3204
  }
3205
+ // Defensive only: these are prompted for when the wearer opens the miniapp, so by the time a
3206
+ // join runs they are either granted or were refused minutes ago on a screen that explained
3207
+ // them. Checked and never requested — a permission dialog on top of a half-built call is
3208
+ // asked too late to explain itself, and ACS fails the join outright without the camera.
3209
+ if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.CAMERA, "camera")))
3210
+ return;
3211
+ if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.MICROPHONE, "microphone")))
3212
+ return;
3142
3213
  const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : "";
3143
3214
  const token = typeof payload.token === "string" ? payload.token : "";
3144
3215
  const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined;
3216
+ // Diagnostic only. A miniapp that does not send it gets `unknown`, which is a third answer in
3217
+ // the comparison rather than a default that would quietly file every old build under "created".
3218
+ const origin = parseAcsCallOrigin(payload.origin);
3145
3219
  if (!meetingUrl || !token) {
3146
3220
  this.sendResult(packageName, requestId, false, undefined, {
3147
3221
  code: MiniappErrorCode.INVALID_ARGUMENT,
@@ -3184,10 +3258,11 @@ class LocalMiniappRuntime {
3184
3258
  transport: videoSource.type,
3185
3259
  hasDisplayName: Boolean(displayName),
3186
3260
  video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
3261
+ origin,
3187
3262
  });
3188
3263
  const startedAt = Date.now();
3189
3264
  try {
3190
- const state = await this.joinSoftapMeeting(packageName, { meetingUrl, token, displayName, video });
3265
+ const state = await this.joinSoftapMeeting(packageName, { meetingUrl, token, displayName, video, origin });
3191
3266
  softapTrace("meeting_join_result", {
3192
3267
  packageName,
3193
3268
  requestId: requestId ?? "none",
@@ -3215,14 +3290,21 @@ class LocalMiniappRuntime {
3215
3290
  token,
3216
3291
  videoSource,
3217
3292
  displayName,
3293
+ origin,
3218
3294
  ...(video ? { video } : {}),
3219
3295
  });
3220
3296
  this.sendResult(packageName, requestId, true, state);
3221
3297
  }
3222
3298
  catch (err) {
3299
+ // A SoftAP failure already knows what it was and where. Flattening every one of them to
3300
+ // INTERNAL is what left the miniapp unable to tell "your Wi-Fi is off" — a condition with a
3301
+ // two-tap fix — from an ACS timeout, so it showed the same shrug for both. The step comes
3302
+ // along too: the same code at `hotspot` and at `scopedJoin` are different stories.
3303
+ const softap = err instanceof SoftapCallError ? err : undefined;
3223
3304
  this.sendResult(packageName, requestId, false, undefined, {
3224
- code: MiniappErrorCode.INTERNAL,
3305
+ code: softap?.code ?? MiniappErrorCode.INTERNAL,
3225
3306
  message: err instanceof Error ? err.message : "ACS meeting join failed",
3307
+ ...(softap ? { step: softap.step } : {}),
3226
3308
  });
3227
3309
  }
3228
3310
  }
@@ -3417,15 +3499,22 @@ class LocalMiniappRuntime {
3417
3499
  meetingUrl: args.meetingUrl,
3418
3500
  token: args.token,
3419
3501
  displayName: args.displayName,
3502
+ video: args.video,
3420
3503
  awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
3421
3504
  subsystems: {
3422
3505
  setHotspotState: async (enabled) => {
3423
- const status = await BluetoothSdk.setHotspotState(enabled);
3506
+ const status = await this.setGlassesHotspotState(enabled);
3424
3507
  if (status.state === "enabled") {
3425
3508
  return { state: status.state, ssid: status.ssid, password: status.password, localIp: status.localIp };
3426
3509
  }
3427
3510
  return { state: status.state };
3428
3511
  },
3512
+ isWifiEnabled: async () => {
3513
+ // A host that cannot answer reports "on" so the preflight stands aside; the native
3514
+ // throw at scopedJoin is what covers those platforms.
3515
+ const enabled = await acsMeetingService.isWifiEnabled();
3516
+ return enabled ?? true;
3517
+ },
3429
3518
  joinScopedNetwork: (ssid, passphrase, gateway) => acsMeetingService.joinScopedNetwork(ssid, passphrase, gateway),
3430
3519
  leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
3431
3520
  cancelScopedNetworkJoin: () => acsMeetingService.cancelScopedNetworkJoin(),
@@ -3437,6 +3526,18 @@ class LocalMiniappRuntime {
3437
3526
  awaitValidatedDefaultNetwork: () => acsMeetingService.awaitValidatedDefaultNetwork(),
3438
3527
  onGlassesStreamStatus: (listener) => {
3439
3528
  const sub = BluetoothSdk.addListener("stream_status", (event) => {
3529
+ // The glasses narrate over BLE, which is slow enough that a status from the previous
3530
+ // publish can land after this attempt was retired — and it is narration, so it would
3531
+ // be written straight onto the new call's checklist as if it were happening now.
3532
+ if (this.softapAttempt !== attempt || attempt.cancelled) {
3533
+ softapTrace("softap_stale_callback", {
3534
+ source: "glasses_stream_status",
3535
+ attempt: attempt.id,
3536
+ status: event.status,
3537
+ current: this.softapAttempt?.id ?? "none",
3538
+ });
3539
+ return;
3540
+ }
3440
3541
  listener({
3441
3542
  status: event.status,
3442
3543
  streamId: event.streamId,
@@ -3451,6 +3552,7 @@ class LocalMiniappRuntime {
3451
3552
  token: options.token,
3452
3553
  videoSource: options.videoSource,
3453
3554
  displayName: options.displayName,
3555
+ origin: args.origin,
3454
3556
  ...(args.video ? { video: args.video } : {}),
3455
3557
  }),
3456
3558
  leaveMeeting: (pkg) => acsMeetingService.leave(pkg),
@@ -3678,8 +3780,10 @@ class LocalMiniappRuntime {
3678
3780
  // Start Call's `teams:create` return `Request failed (503)` — Cloudflare, no internet, not a
3679
3781
  // Teams configuration problem.
3680
3782
  const defaultNetworkStartedAt = Date.now();
3783
+ let defaultNetworkOk = false;
3681
3784
  try {
3682
3785
  const network = await acsMeetingService.awaitDefaultNetworkAfterHotspot();
3786
+ defaultNetworkOk = true;
3683
3787
  softapTrace("softap_teardown_step", {
3684
3788
  attempt: attempt.id,
3685
3789
  step: "awaitValidatedDefaultNetwork",
@@ -3704,9 +3808,12 @@ class LocalMiniappRuntime {
3704
3808
  reason: error instanceof Error ? error.message : String(error),
3705
3809
  });
3706
3810
  }
3811
+ const defaultNetworkMs = Date.now() - defaultNetworkStartedAt;
3707
3812
  const acsLeaveStartedAt = Date.now();
3813
+ let acsLeaveOk = false;
3708
3814
  try {
3709
3815
  const outcome = await acsMeetingService.leaveAndAwait(attempt.packageName);
3816
+ acsLeaveOk = outcome.completed;
3710
3817
  softapTrace("softap_teardown_step", {
3711
3818
  attempt: attempt.id,
3712
3819
  step: "acsLeaveAndAwait",
@@ -3741,6 +3848,10 @@ class LocalMiniappRuntime {
3741
3848
  step: "scopedLostListener",
3742
3849
  outcome: hadScopedLostListener ? "ok" : "not taken",
3743
3850
  });
3851
+ failures.push(...(await this.settleSoftapTeardown(attempt, {
3852
+ acsLeave: { ok: acsLeaveOk, durationMs: Date.now() - acsLeaveStartedAt },
3853
+ defaultNetwork: { ok: defaultNetworkOk, durationMs: defaultNetworkMs },
3854
+ })));
3744
3855
  if (failures.length > 0) {
3745
3856
  this.softapCleanupError = failures.join("; ");
3746
3857
  console.warn("[LocalMiniappRuntime] softap_cleanup_failed", { attempt: attempt.id, failures });
@@ -3759,6 +3870,117 @@ class LocalMiniappRuntime {
3759
3870
  if (endFailure)
3760
3871
  throw endFailure;
3761
3872
  }
3873
+ setGlassesHotspotState(enabled) {
3874
+ const run = this.glassesHotspotCommand.then(() => BluetoothSdk.setHotspotState(enabled), () => BluetoothSdk.setHotspotState(enabled));
3875
+ this.glassesHotspotCommand = run.then(() => undefined, () => undefined);
3876
+ return run;
3877
+ }
3878
+ /**
3879
+ * The last two gates, and the point where a timeout stops being treated as consent.
3880
+ *
3881
+ * Four things have to be true before the next call may start: this device is out of the ACS
3882
+ * call, the WHIP listener's port is released, the glasses have acknowledged their hotspot off,
3883
+ * and the phone has a validated default route again. The first and last already ran; this adds
3884
+ * the two that were never waited for at all, and the ones that produced the stop-then-start
3885
+ * failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
3886
+ *
3887
+ * A bound that expires is not success. It means the resource is still held by a call nobody is
3888
+ * in any more, so it is taken by force and re-verified. Only if *that* fails does the next join
3889
+ * get refused, which is a worse outcome than a slow teardown and a better one than two calls
3890
+ * sharing a port.
3891
+ *
3892
+ * @returns failures to record; a non-empty list refuses the next join.
3893
+ */
3894
+ async settleSoftapTeardown(attempt, earlier) {
3895
+ const ingestStartedAt = Date.now();
3896
+ let ingestClosed = false;
3897
+ try {
3898
+ ingestClosed = await acsMeetingService.awaitIngestClosed(SOFTAP_INGEST_CLOSE_WAIT_MS);
3899
+ }
3900
+ catch (error) {
3901
+ softapTraceFailure("softap_teardown_step", {
3902
+ attempt: attempt.id,
3903
+ step: "awaitIngestClosed",
3904
+ outcome: "failed",
3905
+ reason: error instanceof Error ? error.message : String(error),
3906
+ });
3907
+ }
3908
+ const ingestMs = Date.now() - ingestStartedAt;
3909
+ const hotspotStartedAt = Date.now();
3910
+ let hotspotOff = false;
3911
+ let hotspotReason = "";
3912
+ try {
3913
+ // Idempotent by design — the command asks for "disabled" rather than toggling — so sending
3914
+ // it again after the transport's own stop costs nothing and is the only way to get an ack
3915
+ // that is bound to a deadline we chose.
3916
+ const status = await withTimeout(this.setGlassesHotspotState(false), SOFTAP_HOTSPOT_OFF_ACK_MS, "hotspot_off_ack_timeout");
3917
+ hotspotOff = status.state === "disabled";
3918
+ hotspotReason = status.state;
3919
+ }
3920
+ catch (error) {
3921
+ hotspotReason = error instanceof Error ? error.message : String(error);
3922
+ }
3923
+ const hotspotMs = Date.now() - hotspotStartedAt;
3924
+ softapTrace("softap_teardown_settled", {
3925
+ attempt: attempt.id,
3926
+ acsLeaveMs: earlier.acsLeave.durationMs,
3927
+ acsLeaveOk: earlier.acsLeave.ok,
3928
+ ingestClosedMs: ingestMs,
3929
+ ingestClosed,
3930
+ hotspotOffMs: hotspotMs,
3931
+ hotspotOff,
3932
+ hotspotDetail: hotspotReason,
3933
+ defaultNetworkMs: earlier.defaultNetwork.durationMs,
3934
+ defaultNetworkOk: earlier.defaultNetwork.ok,
3935
+ });
3936
+ if (ingestClosed && hotspotOff)
3937
+ return [];
3938
+ return this.forceSoftapCleanup(attempt, { ingestClosed, hotspotOff, hotspotReason });
3939
+ }
3940
+ /**
3941
+ * Take back what the bounded waits could not confirm was released.
3942
+ *
3943
+ * Runs only after a gate timed out, and re-verifies rather than assuming: the whole reason the
3944
+ * barrier exists is that "it has probably finished by now" is what broke restarts.
3945
+ */
3946
+ async forceSoftapCleanup(attempt, gates) {
3947
+ const failures = [];
3948
+ softapTraceFailure("softap_teardown_forced", {
3949
+ attempt: attempt.id,
3950
+ ingestClosed: gates.ingestClosed,
3951
+ hotspotOff: gates.hotspotOff,
3952
+ hotspotDetail: gates.hotspotReason,
3953
+ });
3954
+ if (!gates.ingestClosed) {
3955
+ try {
3956
+ await acsMeetingService.forceCloseIngest();
3957
+ // Re-asked, not assumed: `forceCloseIngest` skips the tombstone, so the latch should be
3958
+ // open immediately. A short bound here is enough and a failure is real.
3959
+ const closed = await acsMeetingService.awaitIngestClosed(SOFTAP_FORCED_VERIFY_MS);
3960
+ if (!closed)
3961
+ failures.push("the video receiver did not release its port");
3962
+ }
3963
+ catch (error) {
3964
+ failures.push(`forced ingest close: ${error instanceof Error ? error.message : String(error)}`);
3965
+ }
3966
+ }
3967
+ if (!gates.hotspotOff) {
3968
+ try {
3969
+ const status = await withTimeout(this.setGlassesHotspotState(false), SOFTAP_HOTSPOT_OFF_ACK_MS, "hotspot_off_ack_timeout");
3970
+ if (status.state !== "disabled")
3971
+ failures.push(`the glasses hotspot is still ${status.state}`);
3972
+ }
3973
+ catch (error) {
3974
+ failures.push(`glasses hotspot off: ${error instanceof Error ? error.message : String(error)}`);
3975
+ }
3976
+ }
3977
+ softapTrace("softap_teardown_forced_done", {
3978
+ attempt: attempt.id,
3979
+ recovered: failures.length === 0,
3980
+ failures: failures.join("; "),
3981
+ });
3982
+ return failures;
3983
+ }
3762
3984
  async handleMeetingLeave(packageName, requestId) {
3763
3985
  const startedAt = Date.now();
3764
3986
  softapTrace("meeting_leave_request", {