@xenon-device-management/xenon 2.13.1 → 2.13.2

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 (101) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-Cvpkk4r9.js → AnnotationOverlay-BXruClDk.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-Bhn5ZtVM.js → ApiKeyGate-qTS4LPLC.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-BA6aEjTC.js → BugReportButton-BoGhlI7F.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-ChhHnpFQ.js → DeviceMosaicView-8J0oYqMM.js} +1 -1
  6. package/lib/public/assets/{EmptyState-CgzbuUvO.js → EmptyState-Cr17PdDv.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-BHc_5qNc.js → FieldGroup-Bvn08svv.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-D7OMGuRc.js → FilterMenu-DDSO8aDf.js} +1 -1
  9. package/lib/public/assets/{Menu-C173JJ0l.js → Menu-B-Qn2X3T.js} +1 -1
  10. package/lib/public/assets/{Modal-ChvVUI3B.js → Modal-Cg9V5JYU.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Dhw4d_O6.js → RecordingPage-BTUs9VTW.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-z97ftGK-.js → RecordingsPage-DYcfxITK.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-DbXhBGZC.js → SegmentedControl-Zy7suhT5.js} +1 -1
  14. package/lib/public/assets/{SettingCard-C908GgiI.js → SettingCard-CWYmycoe.js} +1 -1
  15. package/lib/public/assets/{Table-B1DB_ZN-.js → Table-io9fwa98.js} +1 -1
  16. package/lib/public/assets/{activity-Css0xNvp.js → activity-Bdf_uIWl.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BMQK3-yN.js → ai-settings-Bce5SJob.js} +1 -1
  18. package/lib/public/assets/{api-keys-CbJ42eDQ.js → api-keys-CPqtVpm3.js} +1 -1
  19. package/lib/public/assets/{apps-DW1pw2re.js → apps-BgZan0H9.js} +1 -1
  20. package/lib/public/assets/{arrow-left-Dpog9L0F.js → arrow-left-B27ckB31.js} +1 -1
  21. package/lib/public/assets/{arrow-right-CsBIlSIw.js → arrow-right-DOF8ITCz.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-z1tRQE4R.js → arrow-up-right-eFG5KTFS.js} +1 -1
  23. package/lib/public/assets/{auth-shell-mXiX5vNk.js → auth-shell-DlC3_27j.js} +2 -2
  24. package/lib/public/assets/{builds-page-BcFn23bE.js → builds-page-BuXpPfKo.js} +1 -1
  25. package/lib/public/assets/{button-Cq_zHETv.js → button-fIW8owMI.js} +1 -1
  26. package/lib/public/assets/{calendar-CKxABX9q.js → calendar-WNjmrF-a.js} +1 -1
  27. package/lib/public/assets/{check-C7xu-hGU.js → check-BdTh1Sq7.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BiqfdCGA.js → chevron-right-B4BKlsqk.js} +1 -1
  29. package/lib/public/assets/{circle-check-CyBjjPxR.js → circle-check-zJU80_EF.js} +1 -1
  30. package/lib/public/assets/{circle-x-6gMRcx6z.js → circle-x-5Uwquo0h.js} +1 -1
  31. package/lib/public/assets/{clock-U1rDTT1h.js → clock-BtUprDsZ.js} +1 -1
  32. package/lib/public/assets/{copy-BIM0ZX0L.js → copy-DGSbZI3u.js} +1 -1
  33. package/lib/public/assets/{device-explorer-DSpAA_t9.js → device-explorer-C-KnhMHI.js} +1 -1
  34. package/lib/public/assets/{download-DDBRXnAZ.js → download-WG6l7BgD.js} +1 -1
  35. package/lib/public/assets/{forgot-password-Dpg7yxM8.js → forgot-password-DnudqFFo.js} +1 -1
  36. package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
  37. package/lib/public/assets/{index-BGBqohfQ.js → index-DI1fGIyj.js} +2 -2
  38. package/lib/public/assets/{index-BQ2luT7L.js → index-HDQVe1gi.js} +1 -1
  39. package/lib/public/assets/{input-BvB1cjHE.js → input-BjDRKCea.js} +1 -1
  40. package/lib/public/assets/{line-chart-m78VggnW.js → line-chart-C0zdgd0v.js} +1 -1
  41. package/lib/public/assets/{list-checks-cY7o1QTL.js → list-checks-BdW5CjM8.js} +1 -1
  42. package/lib/public/assets/{lock-loz8txQm.js → lock-Dy3L4I8E.js} +1 -1
  43. package/lib/public/assets/{login-BYOvz4lx.js → login-BHbY9CLG.js} +1 -1
  44. package/lib/public/assets/{maintenance-settings-BxbA7SkB.js → maintenance-settings-zDVlOMVK.js} +1 -1
  45. package/lib/public/assets/{monitor-C4HPHb6s.js → monitor-8xjkeHHi.js} +1 -1
  46. package/lib/public/assets/{mouse-pointer-2-BkdKtCzi.js → mouse-pointer-2-CXdKMXQ8.js} +1 -1
  47. package/lib/public/assets/{network-C5e0MIWo.js → network-Cj8JOKON.js} +1 -1
  48. package/lib/public/assets/{overview-tlu8ijGd.js → overview-CTg7F0im.js} +1 -1
  49. package/lib/public/assets/{page-header-FXPlo6az.js → page-header--aTyJXq6.js} +1 -1
  50. package/lib/public/assets/{play-CWdk0_Sy.js → play-B-FodgHV.js} +1 -1
  51. package/lib/public/assets/{plus-B2xCnntV.js → plus-CsIk10Cq.js} +1 -1
  52. package/lib/public/assets/{profile-page-D080qzr4.js → profile-page-DkFKkMfl.js} +1 -1
  53. package/lib/public/assets/{recording-group-store-B75435xR.js → recording-group-store-WVM3hoXn.js} +1 -1
  54. package/lib/public/assets/{reset-password-D3u4Mbvn.js → reset-password-I2-zqLlI.js} +1 -1
  55. package/lib/public/assets/{runbook-page-hPRZMy7s.js → runbook-page-DJTVCJX7.js} +1 -1
  56. package/lib/public/assets/{select-12RThAXc.js → select-DvCSv25z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-uQwvV7EQ.js → selector-detail-redirect-BLVfMJs_.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CThoxDFO.js → selector-health-page-oqadRokn.js} +1 -1
  59. package/lib/public/assets/session-detail-page-CGn55Pvl.js +4 -0
  60. package/lib/public/assets/{settings-C1VIluuT.js → settings-MsVU2ukE.js} +1 -1
  61. package/lib/public/assets/{stat-tile-B02WEte8.js → stat-tile-DV9Ix-4O.js} +1 -1
  62. package/lib/public/assets/{tablet-Dj30VGZl.js → tablet-6b_SEN2X.js} +1 -1
  63. package/lib/public/assets/{teams-uL4fjTDR.js → teams-BjZyfuHh.js} +1 -1
  64. package/lib/public/assets/{trash-2-B54e-TfY.js → trash-2-CTZpPSdd.js} +1 -1
  65. package/lib/public/assets/{upload-B3k8WCSP.js → upload-P3XzwQUb.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-BFn90NH3.js → use-builds-data-1cOCMf-z.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DL2TmWZ0.js → use-password-reset-mode-DgF9w088.js} +1 -1
  68. package/lib/public/assets/{users-DjQSucKb.js → users-CiNTKUiz.js} +1 -1
  69. package/lib/public/assets/{users-CqBuxMlX.js → users-DMF9Mk5H.js} +1 -1
  70. package/lib/public/assets/{video-off-T0rlt0GA.js → video-off-CHp1KU_T.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-DAHgnJyZ.js → webhook-settings-DiS8geIm.js} +1 -1
  72. package/lib/public/assets/{zap-gBHqutZV.js → zap-DRz0tDq5.js} +1 -1
  73. package/lib/public/index.html +2 -2
  74. package/lib/schema.json +0 -25
  75. package/lib/src/app/routers/control.js +19 -4
  76. package/lib/src/app/routers/streamStartConflict.js +3 -0
  77. package/lib/src/data-service/device-service.js +40 -0
  78. package/lib/src/data-service/device-store.js +4 -1
  79. package/lib/src/data-service/prisma-store.js +2 -1
  80. package/lib/src/device-utils.js +63 -13
  81. package/lib/src/middleware/deviceAccessGuard.js +63 -9
  82. package/lib/src/services/ServerManager.js +3 -0
  83. package/lib/src/services/SessionLifecycleService.js +16 -4
  84. package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
  85. package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
  86. package/lib/src/services/device-access/leaseHold.js +26 -0
  87. package/lib/src/services/device-access/ticketActorAccess.js +6 -0
  88. package/lib/src/services/lease/activeLeases.js +37 -0
  89. package/lib/src/services/recording/busy-precheck.js +24 -3
  90. package/lib/test/unit/device-access-lease.spec.js +270 -0
  91. package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
  92. package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
  93. package/lib/test/unit/lease-held-phone.spec.js +196 -0
  94. package/lib/test/unit/partial-config-file.spec.js +100 -0
  95. package/lib/test/unit/schema-required-args.spec.js +21 -16
  96. package/lib/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +1 -1
  98. package/schema.json +0 -25
  99. package/scripts/generate-types-from-schema.js +6 -0
  100. package/scripts/lib/always-present-plugin-args.js +39 -0
  101. package/lib/public/assets/session-detail-page-Bc02YjMm.js +0 -4
@@ -88,6 +88,8 @@ const streamLeave_1 = require("./streamLeave");
88
88
  const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
89
89
  const actor_1 = require("../../services/device-access/actor");
90
90
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
91
+ const leaseHold_1 = require("../../services/device-access/leaseHold");
92
+ const activeLeases_1 = require("../../services/lease/activeLeases");
91
93
  /**
92
94
  * The answer for an unknown udid, and for another team's phone (see
93
95
  * deviceTeamGuard): the same JSON on every route. Most routes used to send
@@ -623,7 +625,7 @@ const MJPEG_PROXY_CACHE = new Map();
623
625
  * Start stream endpoint - Initiates WDA and MJPEG streaming for iOS devices
624
626
  */
625
627
  router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
626
- var _a, _b, _c, _d;
628
+ var _a, _b, _c, _d, _e;
627
629
  const { udid } = req.params;
628
630
  // Someone is about to watch: a page that just left (a reload, or another tab
629
631
  // closing) must not stop the stream this page is starting.
@@ -654,6 +656,18 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
654
656
  return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
655
657
  }
656
658
  }
659
+ // Busy with nothing in session_id is a lease's lock: is it the caller's?
660
+ let leaseHolder = false;
661
+ if (device.busy && !device.session_id && !actor.isAdmin) {
662
+ try {
663
+ const held = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, (u, h) => (0, activeLeases_1.activeLeaseOn)(u, h), (id) => ownerResolver.leaseHolderOf(id));
664
+ leaseHolder = (0, deviceAccessPolicy_1.isLeaseHolder)(held, actorUserId, actor.apiKeyId);
665
+ }
666
+ catch (e) {
667
+ logger_1.default.error(`stream/start: lease lookup failed for ${udid}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
668
+ return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
669
+ }
670
+ }
657
671
  const conflict = (0, streamStartConflict_1.decideStreamStartConflict)({
658
672
  udid,
659
673
  busy: !!device.busy,
@@ -663,6 +677,7 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
663
677
  actorUserId,
664
678
  actorApiKeyId: actor.apiKeyId,
665
679
  isAdmin: actor.isAdmin,
680
+ leaseHolder,
666
681
  });
667
682
  if (conflict.action === 'deny') {
668
683
  // holderName lookup is cosmetic — a failure here must still deny (never
@@ -673,7 +688,7 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
673
688
  holderName = yield ownerResolver.displayName(conflict.holderId);
674
689
  }
675
690
  catch (e) {
676
- logger_1.default.warn(`stream/start: holder name lookup failed for ${conflict.holderId}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
691
+ logger_1.default.warn(`stream/start: holder name lookup failed for ${conflict.holderId}: ${(_c = e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`);
677
692
  }
678
693
  }
679
694
  logger_1.default.warn(`Manual Control refused for ${udid}: ${conflict.code} (holder=${conflict.holderId || 'unknown'})`);
@@ -685,11 +700,11 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
685
700
  yield (0, device_service_1.unblockDevice)(udid, device.host);
686
701
  }
687
702
  catch (e) {
688
- logger_1.default.warn(`Failed to clear orphaned lock on ${udid}: ${(_c = e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`);
703
+ logger_1.default.warn(`Failed to clear orphaned lock on ${udid}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
689
704
  }
690
705
  }
691
706
  try {
692
- const h264Cfg = (0, androidH264Config_1.resolveAndroidH264)((_d = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs.streaming) === null || _d === void 0 ? void 0 : _d.androidH264);
707
+ const h264Cfg = (0, androidH264Config_1.resolveAndroidH264)((_e = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs.streaming) === null || _e === void 0 ? void 0 : _e.androidH264);
693
708
  const flagOn = h264Cfg.enabled;
694
709
  // A recording device keeps MJPEG preview so we never run screenrecord and
695
710
  // the recording's screencap pipeline against the same device at once.
@@ -21,6 +21,9 @@ function decideStreamStartConflict(i) {
21
21
  var _a, _b;
22
22
  if (!i.busy)
23
23
  return PROCEED;
24
+ // Busy only by the caller's own lease: their phone to preview.
25
+ if (i.leaseHolder && !i.sessionId)
26
+ return PROCEED;
24
27
  if ((0, manualLock_1.isManualLock)(i.sessionId)) {
25
28
  // Orphan: lock persisted but nothing is serving (server restart, crashed
26
29
  // stop, H.264 stop that skipped unlock). Any caller may reclaim it.
@@ -53,6 +53,7 @@ const prisma_1 = require("../prisma");
53
53
  const manualLock_1 = require("../services/recording/manualLock");
54
54
  const deviceVisibility_1 = require("../services/device-access/deviceVisibility");
55
55
  const DeviceTeamResolver_1 = require("../services/device-access/DeviceTeamResolver");
56
+ const activeLeases_1 = require("../services/lease/activeLeases");
56
57
  // Use a Proxy to ensure we're always using the latest store from the factory,
57
58
  // which is critical for test isolation when the factory cache is cleared.
58
59
  const store = new Proxy({}, {
@@ -434,10 +435,49 @@ function releaseClaimOn(device, ref) {
434
435
  }
435
436
  yield (0, device_utils_1.setUtilizationTime)(device.udid, totalUtilization);
436
437
  logger_1.default.debug(`Released ${JSON.stringify(ref)} on ${device.udid}`);
438
+ if (yield keepLeaseLock(device))
439
+ return true;
437
440
  emitUnblocked(device);
438
441
  return true;
439
442
  });
440
443
  }
444
+ /**
445
+ * A session's phone that an SDK lease still holds goes back to the lease,
446
+ * not to the pool: the release above cleared `busy`, which is the lease's
447
+ * only lock (deviceClaims.ts). Allocation would skip the phone anyway, but
448
+ * the device list, the ownership guard and the dashboard read `busy`. The
449
+ * lease's own end (release, or LeaseOrphanSweeper) clears it. True when the
450
+ * phone was kept for its lease.
451
+ */
452
+ function keepLeaseLock(device) {
453
+ return __awaiter(this, void 0, void 0, function* () {
454
+ try {
455
+ if (!(yield (0, activeLeases_1.activeLeaseOn)(device.udid, device.host)))
456
+ return false;
457
+ // lastCmdExecutedAt, so that if this lock outlives its lease (the undo
458
+ // below fails) the idle sweeper still frees it: it skips the phone only
459
+ // while a live lease holds it.
460
+ yield store.updateDevice(device.udid, device.host, {
461
+ busy: true,
462
+ lastCmdExecutedAt: Date.now(),
463
+ });
464
+ // The lease may have ended between the check and the write; its own
465
+ // release ran before ours and cleared nothing. Then undo, by the same
466
+ // conditional clear it uses. A release after this check runs after the
467
+ // write, and clears it itself.
468
+ if (!(yield (0, activeLeases_1.activeLeaseOn)(device.udid, device.host))) {
469
+ yield store.releaseLeaseLock(device.udid, device.host);
470
+ return false;
471
+ }
472
+ logger_1.default.debug(`Kept ${device.udid} at ${device.host} busy for its lease`);
473
+ return true;
474
+ }
475
+ catch (error) {
476
+ logger_1.default.warn(`Could not check ${device.udid} for a lease after its session: ${error}`);
477
+ return false;
478
+ }
479
+ });
480
+ }
441
481
  /** The phone's total use once the session on it now ends. */
442
482
  function utilizationAfter(device) {
443
483
  var _a;
@@ -246,7 +246,10 @@ class LokiDeviceStore {
246
246
  });
247
247
  const blockedKeys = new Set(activeLeases.map((l) => `${l.deviceUdid}@${l.deviceHost}`));
248
248
  const devices = yield this.getDevices(filterOptions);
249
- const available = devices.find((d) => !d.busy && !d.userBlocked && !blockedKeys.has(`${d.udid}@${d.host}`));
249
+ const available = devices.find((d) => !d.busy &&
250
+ !d.userBlocked &&
251
+ !blockedKeys.has(`${d.udid}@${d.host}`) &&
252
+ (!options.only || options.only.has(`${d.udid}@${d.host}`)));
250
253
  if (available) {
251
254
  yield this.updateDevice(available.udid, available.host, Object.assign({ busy: true }, (options.claim ? { claimedAt: Date.now(), claimSessionId: null } : {})));
252
255
  return available;
@@ -323,7 +323,8 @@ class PrismaDeviceStore {
323
323
  const candidates = yield this.getDevices(Object.assign(Object.assign({}, filterOptions), { busy: false }));
324
324
  // 2. Attempt to atomically lock them one by one, skipping lease-blocked devices
325
325
  for (const device of candidates) {
326
- if (blockedKeys.has(`${device.udid}@${device.host}`)) {
326
+ const key = `${device.udid}@${device.host}`;
327
+ if (blockedKeys.has(key) || (options.only && !options.only.has(key))) {
327
328
  continue;
328
329
  }
329
330
  const now = Date.now();
@@ -99,6 +99,7 @@ const config_1 = require("./config");
99
99
  const device_store_1 = require("./data-service/device-store");
100
100
  const leaseSessionCaps_1 = require("./services/lease/leaseSessionCaps");
101
101
  const deviceVisibility_1 = require("./services/device-access/deviceVisibility");
102
+ const activeLeases_1 = require("./services/lease/activeLeases");
102
103
  // Use a Proxy to ensure we're always using the latest store from the factory,
103
104
  // which is critical for test isolation when the factory cache is cleared.
104
105
  const pendingStore = new Proxy({}, {
@@ -182,6 +183,14 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
182
183
  // text or its stack tells them apart.
183
184
  if (!leasedDevice)
184
185
  throw new Error((0, leaseSessionCaps_1.leaseRefusalMessage)(leaseIdCap));
186
+ // As for an allocated phone below: the idle sweeper reads this session's
187
+ // own timeout from the row, and its idle time from now, not from when the
188
+ // lease was taken. Without it a leased session was ended after the
189
+ // server-wide newCommandTimeoutSec, whatever the session asked for.
190
+ yield (0, device_service_1.updatedAllocatedDevice)(leasedDevice, {
191
+ newCommandTimeout: sessionNewCommandTimeout(firstMatch, pluginArgs),
192
+ lastCmdExecutedAt: Date.now(),
193
+ });
185
194
  return leasedDevice;
186
195
  }
187
196
  const filters = getDeviceFiltersFromCapability(firstMatch, pluginArgs);
@@ -195,29 +204,45 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
195
204
  const intervalBetweenAttempts = firstMatch[customCapability.deviceQueryInterval] || deviceQueryIntervalMs;
196
205
  let device = null;
197
206
  const store = device_store_1.DeviceStoreFactory.getStore();
207
+ // Set once the wait has given up. waitUntil rejects on its timeout but
208
+ // doesn't stop an attempt already running, which could then claim a phone
209
+ // for a create that has failed: a pending claim nobody would ever use.
210
+ let abandoned = false;
198
211
  try {
199
212
  yield (0, async_wait_until_1.default)(() => __awaiter(this, void 0, void 0, function* () {
213
+ if (abandoned)
214
+ return false;
200
215
  const maxSessions = getDeviceManager().getMaxSessionCount();
201
216
  const busyDevicesCount = yield getBusyDevicesCount();
202
217
  if (maxSessions !== undefined && busyDevicesCount === maxSessions) {
203
218
  logger_1.default.info(`Waiting for session available, already at max session count of: ${maxSessions}`);
204
219
  return false;
205
220
  }
206
- // Get matching devices and find one that is not reserved
207
- const matchingDevices = yield (0, device_service_1.getDevices)(filters);
208
- const availableCandidate = matchingDevices.find((d) => !(0, device_service_1.isDeviceReserved)(d));
209
- if (availableCandidate) {
221
+ // Get matching devices that are not reserved
222
+ const candidates = (yield (0, device_service_1.getDevices)(filters)).filter((d) => !(0, device_service_1.isDeviceReserved)(d));
223
+ if (candidates.length > 0) {
210
224
  // Principal Intelligence: Multi-node consistent locking
211
- // Attempt to atomically claim this specific device: busy, with a
212
- // pending claim for the session being created (deviceClaims.ts).
213
- device = yield store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [availableCandidate.udid] }), { claim: true });
214
- return device !== null;
225
+ // Atomically claim one of them: busy, with a pending claim for the
226
+ // session being created (deviceClaims.ts). Every candidate, not just
227
+ // the first: the lock skips a phone an SDK lease holds, and offering
228
+ // it alone stalled the create while other phones were free. Exact
229
+ // (udid, host) pairs: a udid repeats across hosts, and a reserved
230
+ // twin of a candidate must not be locked in its place.
231
+ const locked = yield store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [...new Set(candidates.map((d) => d.udid))] }), { claim: true, only: new Set(candidates.map((d) => `${d.udid}@${d.host}`)) });
232
+ if (locked && abandoned) {
233
+ yield (0, device_service_1.releasePendingClaim)(locked);
234
+ return false;
235
+ }
236
+ device = locked;
237
+ if (device !== null)
238
+ return true;
215
239
  }
216
240
  logger_1.default.info(`Waiting for free device. Filter: ${JSON.stringify(filters)}}`);
217
241
  return false;
218
242
  }), { timeout, intervalBetweenAttempts });
219
243
  }
220
244
  catch (err) {
245
+ abandoned = true;
221
246
  // figure out whether the device is simply busy or non-existent
222
247
  const filterCopy = Object.assign({}, filters);
223
248
  delete filterCopy.busy;
@@ -258,11 +283,9 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
258
283
  // Since findAndLockDevice already sets busy: true, we don't need blockDevice(device.udid, device.host);
259
284
  logger_1.default.info(`📱 Locked device ${lockedDevice.udid} at host ${lockedDevice.host} for new session`);
260
285
  yield updateCapabilityForDevice(capability, lockedDevice);
261
- let newCommandTimeout = firstMatch['appium:newCommandTimeout'];
262
- if (!newCommandTimeout) {
263
- newCommandTimeout = pluginArgs.newCommandTimeoutSec;
264
- }
265
- yield (0, device_service_1.updatedAllocatedDevice)(lockedDevice, { newCommandTimeout });
286
+ yield (0, device_service_1.updatedAllocatedDevice)(lockedDevice, {
287
+ newCommandTimeout: sessionNewCommandTimeout(firstMatch, pluginArgs),
288
+ });
266
289
  return lockedDevice;
267
290
  }
268
291
  else {
@@ -271,6 +294,10 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
271
294
  }
272
295
  });
273
296
  }
297
+ /** The session's own `appium:newCommandTimeout` (seconds), else the server's. */
298
+ function sessionNewCommandTimeout(firstMatch, pluginArgs) {
299
+ return firstMatch['appium:newCommandTimeout'] || pluginArgs.newCommandTimeoutSec;
300
+ }
274
301
  /**
275
302
  * The device a lease-bound session may use, or null when it may not: the lease
276
303
  * is missing, inactive or expired, the session did not prove it holds it, or
@@ -604,6 +631,23 @@ function releaseBlockedDevices(newCommandTimeout) {
604
631
  return __awaiter(this, void 0, void 0, function* () {
605
632
  var _a;
606
633
  const busyDevices = yield unblockCandidateDevices();
634
+ // A phone an SDK lease holds is the lease's to time out (its heartbeats and
635
+ // expiry, LeaseOrphanSweeper), not idle until a session runs on it. A lease
636
+ // sets lastCmdExecutedAt when taken, so without this the sweeper freed a
637
+ // leased phone the new-command timeout later, before a slow first session
638
+ // had even started on it.
639
+ let leases = new Map();
640
+ if (busyDevices.length) {
641
+ try {
642
+ leases = yield (0, activeLeases_1.activeLeasesByDevice)();
643
+ }
644
+ catch (err) {
645
+ // Not knowing which phones are leased, sweeping would free all of them:
646
+ // their lastCmdExecutedAt is when the lease was taken. Next tick.
647
+ logger_1.default.warn(`Idle sweep skipped: could not read active leases: ${err}`);
648
+ return;
649
+ }
650
+ }
607
651
  logger_1.default.debug(`Found ${busyDevices.length} device candidates to be released`);
608
652
  for (const device of busyDevices) {
609
653
  // need to keep this to make typescript happy. good thing tho.
@@ -622,6 +666,12 @@ function releaseBlockedDevices(newCommandTimeout) {
622
666
  }
623
667
  continue;
624
668
  }
669
+ // Leased, with no session or hold on it: not idle.
670
+ if (leases.has((0, activeLeases_1.leaseKey)(device.udid, device.host)) &&
671
+ !device.claimSessionId &&
672
+ !device.session_id) {
673
+ continue;
674
+ }
625
675
  const timeoutSeconds = device.newCommandTimeout != undefined ? device.newCommandTimeout : newCommandTimeout;
626
676
  const timeSinceLastCmdExecuted = (currentEpoch - device.lastCmdExecutedAt) / 1000;
627
677
  if (timeSinceLastCmdExecuted > timeoutSeconds) {
@@ -12,7 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.OWNERSHIP_CHECKED_READS = exports.UNGUARDED_CONTROL_MUTATIONS = void 0;
15
+ exports.LEASE_CHECKED_MUTATIONS = exports.OWNERSHIP_CHECKED_READS = exports.UNGUARDED_CONTROL_MUTATIONS = void 0;
16
16
  exports.deviceAccessGuard = deviceAccessGuard;
17
17
  const typedi_1 = require("typedi");
18
18
  const logger_1 = __importDefault(require("../logger"));
@@ -20,8 +20,10 @@ const manualLock_1 = require("../services/recording/manualLock");
20
20
  const controlDevice_1 = require("./controlDevice");
21
21
  const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
22
22
  const deviceAccessPolicy_1 = require("../services/device-access/deviceAccessPolicy");
23
+ const leaseHold_1 = require("../services/device-access/leaseHold");
23
24
  const actor_1 = require("../services/device-access/actor");
24
25
  const deviceClaims_1 = require("../data-service/deviceClaims");
26
+ const activeLeases_1 = require("../services/lease/activeLeases");
25
27
  const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
26
28
  /**
27
29
  * Mutations under /control that this guard must NOT handle.
@@ -56,6 +58,14 @@ exports.UNGUARDED_CONTROL_MUTATIONS = [
56
58
  * "reads are cheap" assumption the mosaic depends on.
57
59
  */
58
60
  exports.OWNERSHIP_CHECKED_READS = ['clipboard', 'logs'];
61
+ /**
62
+ * Unguarded mutations that still take the lease rule: starting a preview
63
+ * takes a hold on the phone. stream/start's own conflict handling knows
64
+ * holds and sessions, not leases, and on a hub the call goes on to the
65
+ * node, which knows nothing of the hub's leases. So a stranger is refused a
66
+ * leased phone here, before either, and the lease holder passes on to it.
67
+ */
68
+ exports.LEASE_CHECKED_MUTATIONS = ['stream/start'];
59
69
  /**
60
70
  * Refuse /control mutations against a device somebody else holds.
61
71
  *
@@ -65,14 +75,16 @@ exports.OWNERSHIP_CHECKED_READS = ['clipboard', 'logs'];
65
75
  * handlers and was remembered in none of them.
66
76
  */
67
77
  function deviceAccessGuard(deps = {}) {
68
- var _a, _b, _c;
78
+ var _a, _b, _c, _d, _e;
69
79
  const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
70
80
  const resolveSessionOwner = (_b = deps.resolveSessionOwner) !== null && _b !== void 0 ? _b : ((sid) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sid));
71
81
  const describeHolder = (_c = deps.describeHolder) !== null && _c !== void 0 ? _c : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).displayName(id));
82
+ const findActiveLease = (_d = deps.findActiveLease) !== null && _d !== void 0 ? _d : ((udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host));
83
+ const resolveLeaseHolder = (_e = deps.resolveLeaseHolder) !== null && _e !== void 0 ? _e : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(id));
72
84
  const unavailable = (res) => res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
73
85
  return function (req, res, next) {
74
86
  return __awaiter(this, void 0, void 0, function* () {
75
- var _a, _b, _c;
87
+ var _a, _b, _c, _d, _e;
76
88
  // Router-level middleware has no req.params; controlAction reads the path.
77
89
  // It lowercases, as Express routes case-insensitively: `Stream/Start`
78
90
  // still reaches stream/start's own richer handling, and `Clipboard`
@@ -81,8 +93,9 @@ function deviceAccessGuard(deps = {}) {
81
93
  // Decide whether this request is in scope BEFORE touching the udid, so an
82
94
  // ordinary read (screenshot, stream, logs, page source) short-circuits here
83
95
  // exactly as it did when this guard was mutations-only.
96
+ const leaseOnly = req.method === 'POST' && exports.LEASE_CHECKED_MUTATIONS.includes(action);
84
97
  const inScope = STATE_CHANGING.has(req.method)
85
- ? !exports.UNGUARDED_CONTROL_MUTATIONS.includes(action)
98
+ ? !exports.UNGUARDED_CONTROL_MUTATIONS.includes(action) || leaseOnly
86
99
  : req.method === 'GET' && exports.OWNERSHIP_CHECKED_READS.includes(action);
87
100
  if (!action || !inScope)
88
101
  return next();
@@ -116,6 +129,26 @@ function deviceAccessGuard(deps = {}) {
116
129
  // resolved device to reason about ownership at all.
117
130
  if (!device)
118
131
  return next();
132
+ if (leaseOnly) {
133
+ if (actor.isAdmin)
134
+ return next();
135
+ let held;
136
+ try {
137
+ held = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, findActiveLease, resolveLeaseHolder);
138
+ }
139
+ catch (e) {
140
+ logger_1.default.error(`deviceAccessGuard: lease lookup failed for ${udid}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
141
+ return unavailable(res);
142
+ }
143
+ if (!held || (0, deviceAccessPolicy_1.isLeaseHolder)(held, actor.userId, actor.apiKeyId))
144
+ return next();
145
+ return deny(req, res, udid, actor.userId, {
146
+ allow: false,
147
+ code: 'device_held_by_another_user',
148
+ holderId: (_c = held.holderUserId) !== null && _c !== void 0 ? _c : '',
149
+ heldBy: 'lease',
150
+ });
151
+ }
119
152
  let sessionOwnerUserId = null;
120
153
  if (device.busy && device.session_id && !(0, manualLock_1.isManualLock)(device.session_id)) {
121
154
  try {
@@ -124,7 +157,19 @@ function deviceAccessGuard(deps = {}) {
124
157
  catch (e) {
125
158
  // Same reasoning as the device lookup above: not knowing who owns a
126
159
  // live session is exactly the case where we must not guess allow.
127
- logger_1.default.error(`deviceAccessGuard: session owner lookup failed for ${device.session_id}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
160
+ logger_1.default.error(`deviceAccessGuard: session owner lookup failed for ${device.session_id}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
161
+ return unavailable(res);
162
+ }
163
+ }
164
+ let lease = null;
165
+ if (!actor.isAdmin) {
166
+ try {
167
+ lease = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, findActiveLease, resolveLeaseHolder);
168
+ }
169
+ catch (e) {
170
+ // A leased phone is held whatever `busy` says: not knowing whether a
171
+ // lease holds it is not knowing who owns it.
172
+ logger_1.default.error(`deviceAccessGuard: lease lookup failed for ${udid}: ${(_e = e === null || e === void 0 ? void 0 : e.message) !== null && _e !== void 0 ? _e : e}`);
128
173
  return unavailable(res);
129
174
  }
130
175
  }
@@ -138,9 +183,16 @@ function deviceAccessGuard(deps = {}) {
138
183
  actorUserId: actor.userId,
139
184
  actorApiKeyId: actor.apiKeyId,
140
185
  isAdmin: actor.isAdmin,
186
+ lease,
141
187
  });
142
188
  if (decision.allow)
143
189
  return next();
190
+ return deny(req, res, udid, actor.userId, decision);
191
+ });
192
+ };
193
+ function deny(req, res, udid, actorUserId, decision) {
194
+ return __awaiter(this, void 0, void 0, function* () {
195
+ var _a;
144
196
  // describeHolder is cosmetic — it only resolves a display name for the
145
197
  // deny message. A failure here must still deny (never flip to allow) but
146
198
  // must not hang the request either; fall back to no holder name.
@@ -150,12 +202,14 @@ function deviceAccessGuard(deps = {}) {
150
202
  holderName = yield describeHolder(decision.holderId);
151
203
  }
152
204
  catch (e) {
153
- logger_1.default.warn(`deviceAccessGuard: holder name lookup failed for ${decision.holderId}: ${(_c = e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`);
205
+ logger_1.default.warn(`deviceAccessGuard: holder name lookup failed for ${decision.holderId}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
154
206
  }
155
207
  }
156
- logger_1.default.warn(`Device access denied: ${actor.userId} -> ${req.method} ${req.originalUrl} ` +
208
+ logger_1.default.warn(`Device access denied: ${actorUserId} -> ${req.method} ${req.originalUrl} ` +
157
209
  `on ${udid} (${decision.code}, holder=${decision.holderId || 'unknown'})`);
158
- return res.status(409).json((0, deviceAccessPolicy_1.denyBody)(decision.code, decision.holderId, holderName));
210
+ return res
211
+ .status(409)
212
+ .json((0, deviceAccessPolicy_1.denyBody)(decision.code, decision.holderId, holderName, decision.heldBy));
159
213
  });
160
- };
214
+ }
161
215
  }
@@ -71,6 +71,7 @@ const LogcatStreamService_1 = require("../device-managers/android/LogcatStreamSe
71
71
  const IOSLogStreamService_1 = require("../device-managers/ios/IOSLogStreamService");
72
72
  const logSource_1 = require("./logcat/logSource");
73
73
  const SessionOwnerResolver_1 = require("./device-access/SessionOwnerResolver");
74
+ const activeLeases_1 = require("./lease/activeLeases");
74
75
  const ticketActorAccess_1 = require("./device-access/ticketActorAccess");
75
76
  // enable resolveJsonModule in tsconfig must be true for this to work
76
77
  const package_json_1 = __importDefault(require("../../package.json"));
@@ -169,6 +170,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
169
170
  authorize: (0, ticketActorAccess_1.makeTicketActorAuthorizer)({
170
171
  findDevice: (udid) => device_store_1.DeviceStoreFactory.getStore().findDevice({ udid }),
171
172
  resolveSessionOwner: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId),
173
+ findActiveLease: (udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host),
174
+ resolveLeaseHolder: (actorId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(actorId),
172
175
  }),
173
176
  // One WebSocket, two transports. The device's platform picks which —
174
177
  // see resolveLogSource. The filter is only meaningful for ostrace,
@@ -222,9 +222,7 @@ let SessionLifecycleService = class SessionLifecycleService {
222
222
  // Who asked, for the queue's team scoping (queueVisibility). On this row
223
223
  // only, never in `caps`, and last so a client can't supply its own.
224
224
  [queueVisibility_1.REQUESTER_KEY]: authResult.requester }));
225
- const lockName = this.getLockName(caps);
226
- this.logger.debug(`📱 Acquiring lock: ${lockName}`);
227
- const device = yield commandsQueueGuard.acquire(lockName, () => __awaiter(this, void 0, void 0, function* () {
225
+ const allocate = () => __awaiter(this, void 0, void 0, function* () {
228
226
  var _a;
229
227
  try {
230
228
  return yield (0, device_utils_1.allocateDeviceForSession)(caps, pluginArgs.deviceAvailabilityTimeoutMs, pluginArgs.deviceAvailabilityQueryIntervalMs, pluginArgs, sessionTeamIds, {
@@ -238,7 +236,21 @@ let SessionLifecycleService = class SessionLifecycleService {
238
236
  yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
239
237
  throw err;
240
238
  }
241
- }));
239
+ });
240
+ // Creates for a platform queue on one lock, first come first served, and
241
+ // each has its full deviceAvailabilityTimeoutMs from the head of the
242
+ // queue. A lease-bound session allocates nothing (its phone is the
243
+ // lease's) and doesn't queue: behind the lock it waited for every create
244
+ // ahead of it, each of which may wait minutes for a busy phone.
245
+ let device;
246
+ if ((0, leaseSessionCaps_1.leaseIdOf)(caps)) {
247
+ device = yield allocate();
248
+ }
249
+ else {
250
+ const lockName = this.getLockName(caps);
251
+ this.logger.debug(`📱 Acquiring lock: ${lockName}`);
252
+ device = yield commandsQueueGuard.acquire(lockName, allocate);
253
+ }
242
254
  yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Allocating node resources...');
243
255
  this.logger.debug(`device.host: ${device.host} and pluginArgs.bindHostOrIp: ${pluginArgs.bindHostOrIp}`);
244
256
  const allocation = {
@@ -46,9 +46,34 @@ let SessionOwnerResolver = class SessionOwnerResolver {
46
46
  this.db = db;
47
47
  this.ownerCache = new Map();
48
48
  this.nameCache = new Map();
49
+ this.leaseHolderCache = new Map();
49
50
  /** The owners of this server's live sessions. A field, not a constructor parameter: TypeDI. */
50
51
  this.liveOwners = () => typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners);
51
52
  }
53
+ /**
54
+ * The user behind a lease's `actorId`: the owner of the API key that
55
+ * created it, or, for a lease made with a bearer token, the user id itself.
56
+ * Positive results are cached, as for sessions: a lease's creator never
57
+ * changes.
58
+ */
59
+ leaseHolderOf(actorId) {
60
+ return __awaiter(this, void 0, void 0, function* () {
61
+ var _a;
62
+ if (!actorId)
63
+ return null;
64
+ const cached = this.leaseHolderCache.get(actorId);
65
+ if (cached)
66
+ return cached;
67
+ const key = yield this.db.apiKey.findUnique({
68
+ where: { id: actorId },
69
+ select: { userId: true },
70
+ });
71
+ const holder = key ? ((_a = key.userId) !== null && _a !== void 0 ? _a : null) : actorId;
72
+ if (holder)
73
+ this.remember(this.leaseHolderCache, actorId, holder);
74
+ return holder;
75
+ });
76
+ }
52
77
  ownerOf(sessionId) {
53
78
  return __awaiter(this, void 0, void 0, function* () {
54
79
  var _a, _b;
@@ -165,6 +190,7 @@ let SessionOwnerResolver = class SessionOwnerResolver {
165
190
  clear() {
166
191
  this.ownerCache.clear();
167
192
  this.nameCache.clear();
193
+ this.leaseHolderCache.clear();
168
194
  }
169
195
  remember(cache, key, value) {
170
196
  if (cache.size >= MAX_CACHE_ENTRIES)
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isSelfManualLock = isSelfManualLock;
4
4
  exports.isOwnSession = isOwnSession;
5
+ exports.isLeaseHolder = isLeaseHolder;
5
6
  exports.evaluateDeviceAccess = evaluateDeviceAccess;
6
7
  exports.ownershipUnavailableBody = ownershipUnavailableBody;
7
8
  exports.denyBody = denyBody;
@@ -23,10 +24,39 @@ function isOwnSession(sessionOwnerUserId, actorUserId) {
23
24
  const owner = sessionOwnerUserId !== null && sessionOwnerUserId !== void 0 ? sessionOwnerUserId : '';
24
25
  return !!(owner && actorUserId && owner === actorUserId);
25
26
  }
27
+ /**
28
+ * True when the caller holds the lease: by the key that created it, or as
29
+ * the user behind it (any credential of theirs, the dashboard included), as
30
+ * ownership is keyed on the user everywhere else.
31
+ */
32
+ function isLeaseHolder(lease, actorUserId, actorApiKeyId) {
33
+ if (!lease)
34
+ return false;
35
+ if (actorApiKeyId && lease.actorId === actorApiKeyId)
36
+ return true;
37
+ return !!actorUserId && (lease.actorId === actorUserId || lease.holderUserId === actorUserId);
38
+ }
26
39
  function evaluateDeviceAccess(input) {
27
- var _a;
40
+ var _a, _b;
28
41
  if (input.isAdmin)
29
42
  return ALLOW;
43
+ if (input.lease) {
44
+ if (isLeaseHolder(input.lease, input.actorUserId, input.actorApiKeyId))
45
+ return ALLOW;
46
+ // A session on a leased phone proved it may use the lease; its owner
47
+ // keeps driving it, as with any session of their own.
48
+ if (input.sessionId &&
49
+ !(0, manualLock_1.isManualLock)(input.sessionId) &&
50
+ isOwnSession(input.sessionOwnerUserId, input.actorUserId)) {
51
+ return ALLOW;
52
+ }
53
+ return {
54
+ allow: false,
55
+ code: 'device_held_by_another_user',
56
+ holderId: (_a = input.lease.holderUserId) !== null && _a !== void 0 ? _a : '',
57
+ heldBy: 'lease',
58
+ };
59
+ }
30
60
  if (!input.busy)
31
61
  return ALLOW;
32
62
  const asUser = (0, manualLock_1.inspectManualLock)(input.sessionId, input.actorUserId, input.udid);
@@ -42,7 +72,7 @@ function evaluateDeviceAccess(input) {
42
72
  }
43
73
  // Not a manual lock: a real Appium session holds the device (or the row is
44
74
  // busy with no id at all, which is an inconsistent state we fail closed on).
45
- const owner = (_a = input.sessionOwnerUserId) !== null && _a !== void 0 ? _a : '';
75
+ const owner = (_b = input.sessionOwnerUserId) !== null && _b !== void 0 ? _b : '';
46
76
  if (isOwnSession(input.sessionOwnerUserId, input.actorUserId))
47
77
  return ALLOW;
48
78
  return { allow: false, code: 'device_in_use_by_session', holderId: owner };
@@ -62,11 +92,13 @@ function ownershipUnavailableBody() {
62
92
  message: 'Could not verify device ownership. Try again.',
63
93
  };
64
94
  }
65
- function denyBody(code, holderId, holderName) {
95
+ function denyBody(code, holderId, holderName, heldBy) {
66
96
  const who = holderName || 'another user';
67
- const message = code === 'device_held_by_another_user'
68
- ? `Device is being controlled by ${who}. Ask them to release it, or use an admin key to force-release.`
69
- : `Device is in use by an Appium session owned by ${who}.`;
97
+ const message = heldBy === 'lease'
98
+ ? `Device is leased by ${who} through the SDK. It is free again when their lease ends.`
99
+ : code === 'device_held_by_another_user'
100
+ ? `Device is being controlled by ${who}. Ask them to release it, or use an admin key to force-release.`
101
+ : `Device is in use by an Appium session owned by ${who}.`;
70
102
  return {
71
103
  success: false,
72
104
  error: code,
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.leaseHoldFor = leaseHoldFor;
13
+ /**
14
+ * The lease hold on a device row for an ownership decision, or null. Throws
15
+ * when either lookup does: the caller fails closed.
16
+ */
17
+ function leaseHoldFor(device, findActiveLease, resolveLeaseHolder) {
18
+ return __awaiter(this, void 0, void 0, function* () {
19
+ if (!(device === null || device === void 0 ? void 0 : device.udid) || !device.host)
20
+ return null;
21
+ const lease = yield findActiveLease(device.udid, device.host);
22
+ if (!lease)
23
+ return null;
24
+ return { actorId: lease.actorId, holderUserId: yield resolveLeaseHolder(lease.actorId) };
25
+ });
26
+ }