@xenon-device-management/xenon 2.0.0 → 2.1.0

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 (279) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +1 -2
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-C-Pw_hvw.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-DmYQ0oSk.js} +1 -1
  5. package/lib/public/assets/BugReportButton-n8T1dFUp.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-D-rGOBbJ.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-DExETevp.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-CCdFlZm6.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-DiUSOSL-.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-ZOfpH6gh.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-CZNtoB-1.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-BKp6h8Jl.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-Bf881FX4.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-EUoyizvW.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-ClT4hf4_.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-ucR4bnmc.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-K-hdylwq.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-BLhdwI3c.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-BbiN7rBI.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-Dez1petz.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-UjmFHLAJ.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-BVyEigbu.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-B-ZlBof3.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-DAQjAgUv.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-CmIPa5JK.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-Dd-IhMC1.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-CAIeJwa5.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-C4Jrjg8f.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-BGzlf-4i.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-_wYqDjDC.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-Ckn00yMB.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-pdVkomLa.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-B8vmwkHj.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-BAqnFI-F.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CYq_4F_N.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-B6UEAp_1.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-CLqNvmrN.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-Da90BBbT.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-BuIIcmGp.js} +2 -2
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-MX7IzqyW.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-Bq9aY-We.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CIhXIJyP.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-bXjpswQI.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-pJ72y5Fy.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-UOcHALV2.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-XnJgp5yb.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-QkAhI1Jx.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-B42Da0m4.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-CKVqcyp6.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-BORGkN3N.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CKWFOjOI.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-BpFKVdC8.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-CcbKBicY.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BA0W9bxl.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Cp7ZBq3s.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-DIZWIoVR.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C_4YuvAp.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CXjD_Xth.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CTqIHeoG.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-B0KYkk8z.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-BcRBKUXd.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-Dh2PXSf_.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-_sXqZztt.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-DGHT8MKm.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV88cV3Z.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-CIDH9wkj.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-DYGuSNwl.js} +1 -1
  69. package/lib/public/assets/{users-CEJ3fC78.js → users-7yviqflc.js} +1 -1
  70. package/lib/public/assets/{users-Drd7ifJx.js → users-BUE7MoTq.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-BTRxtlGo.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-DMKD9Pgm.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-CWE94qcK.js} +1 -1
  74. package/lib/public/index.html +1 -1
  75. package/lib/src/InternalHttpClient.js +5 -1
  76. package/lib/src/XenonCapabilityManager.js +15 -1
  77. package/lib/src/app/appiumBasePath.js +32 -0
  78. package/lib/src/app/index.js +12 -0
  79. package/lib/src/app/insertBeforeRoutes.js +24 -4
  80. package/lib/src/app/registerCommandAuth.js +152 -33
  81. package/lib/src/app/routers/control.js +57 -13
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/ws/h264StreamWs.js +13 -8
  84. package/lib/src/app/ws/logcatWs.js +12 -7
  85. package/lib/src/app/ws/upgradeRouter.js +215 -0
  86. package/lib/src/dashboard/event-manager.js +7 -6
  87. package/lib/src/data-service/device-service.js +152 -49
  88. package/lib/src/data-service/device-store.js +81 -10
  89. package/lib/src/data-service/deviceClaims.js +98 -0
  90. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  91. package/lib/src/data-service/prisma-store.js +94 -31
  92. package/lib/src/device-managers/AndroidDeviceManager.js +13 -15
  93. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  94. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  95. package/lib/src/device-managers/NodeDevices.js +7 -2
  96. package/lib/src/device-managers/android/AndroidH264StreamService.js +3 -3
  97. package/lib/src/device-managers/android/AndroidStreamService.js +6 -5
  98. package/lib/src/device-managers/android/previewHold.js +2 -2
  99. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  100. package/lib/src/device-managers/ios/IOSStreamService.js +158 -38
  101. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  102. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  103. package/lib/src/device-utils.js +78 -24
  104. package/lib/src/gateway/createHandoff.js +73 -0
  105. package/lib/src/gateway/defaultGateway.js +80 -0
  106. package/lib/src/gateway/forwardToNode.js +147 -0
  107. package/lib/src/gateway/hubSessionToken.js +356 -0
  108. package/lib/src/gateway/internalCall.js +90 -0
  109. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  110. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  111. package/lib/src/gateway/sessionCreate.js +136 -0
  112. package/lib/src/gateway/sessionGateway.js +201 -0
  113. package/lib/src/gateway/sessionLocator.js +100 -0
  114. package/lib/src/generated/client/edge.js +7 -4
  115. package/lib/src/generated/client/index-browser.js +4 -1
  116. package/lib/src/generated/client/index.d.ts +112 -1
  117. package/lib/src/generated/client/index.js +7 -4
  118. package/lib/src/generated/client/package.json +1 -1
  119. package/lib/src/generated/client/schema.prisma +9 -0
  120. package/lib/src/generated/client/wasm.js +4 -1
  121. package/lib/src/helpers/index.js +5 -1
  122. package/lib/src/logger.js +5 -0
  123. package/lib/src/middleware/authMiddleware.js +80 -0
  124. package/lib/src/middleware/commandAuth.js +31 -17
  125. package/lib/src/middleware/csrfMiddleware.js +3 -2
  126. package/lib/src/middleware/sessionListingFilter.js +153 -0
  127. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  128. package/lib/src/plugin.js +27 -1
  129. package/lib/src/prisma.js +2 -1
  130. package/lib/src/services/DeviceReconciler.js +5 -2
  131. package/lib/src/services/OrphanSweeper.js +12 -19
  132. package/lib/src/services/PortAllocator.js +75 -16
  133. package/lib/src/services/ServerManager.js +50 -30
  134. package/lib/src/services/SessionLifecycleService.js +335 -76
  135. package/lib/src/services/ShutdownCoordinator.js +7 -1
  136. package/lib/src/services/SocketServer.js +11 -2
  137. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  138. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  139. package/lib/src/services/lease/LeaseOrphanSweeper.js +32 -6
  140. package/lib/src/services/lease/LeaseService.js +19 -5
  141. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  142. package/lib/src/services/session/sessionCredentials.js +7 -59
  143. package/lib/src/sessions/CloudSession.js +12 -0
  144. package/lib/src/sessions/LocalSession.js +52 -26
  145. package/lib/src/sessions/RemoteSession.js +116 -9
  146. package/lib/src/sessions/SessionManager.js +49 -6
  147. package/lib/src/sessions/appiumUmbrella.js +32 -0
  148. package/lib/test/helpers/appium-umbrella.js +84 -0
  149. package/lib/test/helpers/fake-device-table.js +97 -0
  150. package/lib/test/helpers/loki-stores.js +41 -0
  151. package/lib/test/helpers/loopbackRequest.js +77 -0
  152. package/lib/test/helpers/scratch-database.js +100 -0
  153. package/lib/test/helpers/scratch-port-leases.js +119 -0
  154. package/lib/test/helpers/test-container.js +35 -27
  155. package/lib/test/integration/androidDevices.spec.js +5 -4
  156. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  157. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  158. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  159. package/lib/test/unit/AndroidDeviceManager.spec.js +8 -1
  160. package/lib/test/unit/DeviceModel.spec.js +9 -9
  161. package/lib/test/unit/HealingCascade.test.js +5 -1
  162. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  163. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  164. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  165. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  166. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  167. package/lib/test/unit/android-h264-service.spec.js +6 -0
  168. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  169. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  170. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  171. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  172. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  173. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  174. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  175. package/lib/test/unit/authMiddleware.test.js +3 -0
  176. package/lib/test/unit/bug-report/route.spec.js +9 -9
  177. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  178. package/lib/test/unit/clipboard-route.spec.js +18 -8
  179. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  180. package/lib/test/unit/command-auth.spec.js +31 -31
  181. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  182. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  183. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  184. package/lib/test/unit/create-error-message.spec.js +43 -0
  185. package/lib/test/unit/create-handoff.spec.js +86 -0
  186. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  187. package/lib/test/unit/device-access-guard.spec.js +28 -28
  188. package/lib/test/unit/device-service.spec.js +4 -0
  189. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  190. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  191. package/lib/test/unit/device-team-guard.spec.js +20 -20
  192. package/lib/test/unit/device-utils.spec.js +8 -0
  193. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  194. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  195. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  196. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  197. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  198. package/lib/test/unit/helpers.spec.js +27 -0
  199. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  200. package/lib/test/unit/hub-create-once.spec.js +190 -0
  201. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  202. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  203. package/lib/test/unit/hub-node-control-forward.spec.js +291 -0
  204. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  205. package/lib/test/unit/hub-node-race.spec.js +401 -0
  206. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  207. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  208. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  209. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  210. package/lib/test/unit/hub-session-token.spec.js +265 -0
  211. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  212. package/lib/test/unit/install-repository-app-team.spec.js +2 -2
  213. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  214. package/lib/test/unit/internal-call.spec.js +181 -0
  215. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  216. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  217. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  218. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  219. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  220. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  221. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  222. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  223. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +92 -0
  224. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  225. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  226. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  227. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  228. package/lib/test/unit/logcat-ws.spec.js +4 -1
  229. package/lib/test/unit/loopback-request.spec.js +81 -0
  230. package/lib/test/unit/node-create-caps.spec.js +54 -0
  231. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  232. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  233. package/lib/test/unit/omni-vision.spec.js +7 -3
  234. package/lib/test/unit/plugin.spec.js +45 -4
  235. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  236. package/lib/test/unit/profile-router.test.js +11 -11
  237. package/lib/test/unit/queue-requester.spec.js +5 -0
  238. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  239. package/lib/test/unit/queue-visibility.spec.js +1 -1
  240. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  241. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  242. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  243. package/lib/test/unit/recording-store.spec.js +9 -3
  244. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  245. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  246. package/lib/test/unit/remote-session-create.spec.js +130 -0
  247. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  248. package/lib/test/unit/reset-link.test.js +14 -14
  249. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  250. package/lib/test/unit/session-assets.spec.js +9 -9
  251. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  252. package/lib/test/unit/session-create-layer.spec.js +255 -0
  253. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  254. package/lib/test/unit/session-credentials.spec.js +0 -47
  255. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  256. package/lib/test/unit/session-locator.spec.js +250 -0
  257. package/lib/test/unit/session-node-calls.spec.js +174 -0
  258. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  259. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  260. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  261. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  262. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  263. package/lib/test/unit/stream-leave-route.spec.js +15 -7
  264. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  265. package/lib/test/unit/stream-stop-ownership.spec.js +13 -5
  266. package/lib/test/unit/stream-stop-recording.spec.js +13 -5
  267. package/lib/test/unit/stream-ticket-identity.spec.js +15 -5
  268. package/lib/test/unit/upgrade-router.spec.js +446 -0
  269. package/lib/test/unit/users-router.test.js +11 -8
  270. package/lib/test/unit/verify-credential.spec.js +4 -1
  271. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  272. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  273. package/lib/tsconfig.tsbuildinfo +1 -1
  274. package/package.json +1 -2
  275. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  276. package/prisma/schema.prisma +9 -0
  277. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  278. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  279. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -13,6 +13,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.DeviceStoreFactory = void 0;
16
+ const deviceFieldOwners_1 = require("./deviceFieldOwners");
17
+ const deviceClaims_1 = require("./deviceClaims");
16
18
  const logger_1 = __importDefault(require("../logger"));
17
19
  const semver_1 = __importDefault(require("semver"));
18
20
  const db_1 = require("./db");
@@ -153,19 +155,37 @@ class LokiDeviceStore {
153
155
  });
154
156
  });
155
157
  }
156
- updateDevices(filter, updateFn) {
158
+ touchSession(sessionId, at) {
157
159
  return __awaiter(this, void 0, void 0, function* () {
158
- (yield db_1.XenonDatabase.DeviceModel).chain().find(filter).update(updateFn);
160
+ (yield db_1.XenonDatabase.DeviceModel)
161
+ .chain()
162
+ .find({ session_id: sessionId })
163
+ .update((device) => {
164
+ device.lastCmdExecutedAt = at;
165
+ });
159
166
  });
160
167
  }
161
- addDevices(devices) {
162
- return __awaiter(this, void 0, void 0, function* () {
168
+ addDevices(devices_1) {
169
+ return __awaiter(this, arguments, void 0, function* (devices, options = {}) {
163
170
  const deviceModel = yield db_1.XenonDatabase.DeviceModel;
164
171
  const added = [];
165
172
  for (const device of devices) {
166
173
  const existing = deviceModel.findOne({ udid: device.udid, host: device.host });
174
+ // A node's report (PrismaDeviceStore.addNodeReport has the rule).
175
+ const nodeBusy = device.busy === true;
176
+ if (existing && options.nodeReport) {
177
+ Object.assign(existing, (0, deviceFieldOwners_1.pickNodeReportFields)(device), { nodeBusy });
178
+ if (nodeBusy)
179
+ existing.busy = true;
180
+ else if ((0, deviceClaims_1.isUnheld)(existing))
181
+ existing.busy = false;
182
+ deviceModel.update(existing);
183
+ continue;
184
+ }
167
185
  if (!existing) {
168
- const cleanDevice = Object.assign({}, device);
186
+ const cleanDevice = options.nodeReport
187
+ ? Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(device)), { udid: device.udid, host: device.host, busy: nodeBusy, nodeBusy })
188
+ : Object.assign({}, device);
169
189
  if (cleanDevice.host === undefined)
170
190
  cleanDevice.host = 'Local';
171
191
  if (cleanDevice.userBlocked === undefined)
@@ -185,14 +205,24 @@ class LokiDeviceStore {
185
205
  return added;
186
206
  });
187
207
  }
208
+ // Loki's find() matches a host exactly already, so `exactHost` changes nothing here.
188
209
  removeDevices(filter) {
189
210
  return __awaiter(this, void 0, void 0, function* () {
190
211
  (yield db_1.XenonDatabase.DeviceModel).chain().find(filter).remove();
191
212
  });
192
213
  }
193
- clearStorage() {
214
+ clearStorage(onlyHosts) {
194
215
  return __awaiter(this, void 0, void 0, function* () {
195
- (yield db_1.XenonDatabase.DeviceModel).removeDataOnly();
216
+ const model = yield db_1.XenonDatabase.DeviceModel;
217
+ if (onlyHosts) {
218
+ model
219
+ .chain()
220
+ .find({ host: { $in: [...onlyHosts] } })
221
+ .remove();
222
+ }
223
+ else {
224
+ model.removeDataOnly();
225
+ }
196
226
  });
197
227
  }
198
228
  findDevice(filter) {
@@ -205,8 +235,8 @@ class LokiDeviceStore {
205
235
  return (yield db_1.XenonDatabase.DeviceModel).find(filter);
206
236
  });
207
237
  }
208
- findAndLockDevice(filterOptions) {
209
- return __awaiter(this, void 0, void 0, function* () {
238
+ findAndLockDevice(filterOptions_1) {
239
+ return __awaiter(this, arguments, void 0, function* (filterOptions, options = {}) {
210
240
  // Phase 2: exclude devices held by an active lease.
211
241
  const activeLeases = yield prisma_1.prisma.lease.findMany({
212
242
  where: { status: 'active' },
@@ -216,12 +246,53 @@ class LokiDeviceStore {
216
246
  const devices = yield this.getDevices(filterOptions);
217
247
  const available = devices.find((d) => !d.busy && !d.userBlocked && !blockedKeys.has(`${d.udid}@${d.host}`));
218
248
  if (available) {
219
- yield this.updateDevice(available.udid, available.host, { busy: true });
249
+ yield this.updateDevice(available.udid, available.host, Object.assign({ busy: true }, (options.claim ? { claimedAt: Date.now(), claimSessionId: null } : {})));
220
250
  return available;
221
251
  }
222
252
  return null;
223
253
  });
224
254
  }
255
+ claimForSession(udid, host, claimedAt, sessionId, update) {
256
+ return __awaiter(this, void 0, void 0, function* () {
257
+ const model = yield db_1.XenonDatabase.DeviceModel;
258
+ const device = model.findOne({ udid, host });
259
+ if (!device || device.claimSessionId != null)
260
+ return false;
261
+ if (device.claimedAt != null && device.claimedAt !== claimedAt)
262
+ return false;
263
+ Object.assign(device, update, {
264
+ busy: true,
265
+ claimSessionId: sessionId,
266
+ claimedAt: claimedAt !== null && claimedAt !== void 0 ? claimedAt : Date.now(),
267
+ });
268
+ model.update(device);
269
+ return true;
270
+ });
271
+ }
272
+ releaseClaim(udid, host, ref, update) {
273
+ return __awaiter(this, void 0, void 0, function* () {
274
+ const model = yield db_1.XenonDatabase.DeviceModel;
275
+ const device = model.findOne({ udid, host });
276
+ if (!device || !(0, deviceClaims_1.holdsClaim)(device, ref))
277
+ return false;
278
+ Object.assign(device, update, deviceClaims_1.CLAIM_RESET);
279
+ if ((0, deviceClaims_1.isUnheld)(device))
280
+ device.busy = false;
281
+ model.update(device);
282
+ return true;
283
+ });
284
+ }
285
+ releaseLeaseLock(udid, host) {
286
+ return __awaiter(this, void 0, void 0, function* () {
287
+ const model = yield db_1.XenonDatabase.DeviceModel;
288
+ const device = model.findOne({ udid, host });
289
+ if (!(device === null || device === void 0 ? void 0 : device.busy) || !(0, deviceClaims_1.isUnheld)(device))
290
+ return false;
291
+ device.busy = false;
292
+ model.update(device);
293
+ return true;
294
+ });
295
+ }
225
296
  resetMetrics() {
226
297
  return __awaiter(this, void 0, void 0, function* () {
227
298
  (yield db_1.XenonDatabase.DeviceModel)
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLAIM_RESET = exports.UNHELD = exports.NO_CLAIM = exports.PENDING_CLAIM_TIMEOUT_MS = void 0;
4
+ exports.isPendingClaim = isPendingClaim;
5
+ exports.pendingClaimExpired = pendingClaimExpired;
6
+ exports.claimWhere = claimWhere;
7
+ exports.holdsClaim = holdsClaim;
8
+ exports.hasNoClaim = hasNoClaim;
9
+ exports.isUnheld = isUnheld;
10
+ /**
11
+ * Why a phone is busy, on the server that allocates it.
12
+ *
13
+ * Allocation reads `busy` (findAndLockDevice takes a phone only where it is
14
+ * false, in one conditional update). On a hub a node's phone is busy for one
15
+ * of two reasons, each kept in its own columns so neither can erase the
16
+ * other:
17
+ *
18
+ * - **The claim**, this server's: `claimedAt` (when allocation took the phone)
19
+ * and `claimSessionId` (the session's id, written when the session exists).
20
+ * A claim with no session id yet is *pending*: its session is being
21
+ * created, which can take minutes on a first driver install.
22
+ * - **The node's report**: `nodeBusy`, what the node last said
23
+ * (`POST /xenon/api/register`). Always false for a phone this server drives.
24
+ *
25
+ * `busy` is claim OR report. No write that can clear it reads the row first:
26
+ *
27
+ * - A node reporting its phone free clears `busy` only where nothing of the
28
+ * hub's holds it (UNHELD: no claim, and no hold such as a preview or a
29
+ * recording in `session_id`), in one conditional update. A report sent
30
+ * before the hub's claim (every 30 s by default) can no longer undo it.
31
+ * - A release names the claim it ends (ClaimRef): a session's id, or when a
32
+ * pending claim was taken. A stale release, for a session that ended while
33
+ * the phone went on to another, matches nothing. Then `busy` is cleared
34
+ * only where nothing else holds the phone (UNHELD), again one conditional
35
+ * update: a node still reporting it busy keeps it busy.
36
+ * - The idle sweeper leaves a pending claim alone until
37
+ * PENDING_CLAIM_TIMEOUT_MS, whatever the new-command timeout.
38
+ *
39
+ * A server with no nodes (standalone, or a node itself) never has `nodeBusy`
40
+ * set, so `busy` is its claim as it always was.
41
+ *
42
+ * Only a session's allocation takes a claim. A lease locks with `busy` alone,
43
+ * as before; a manual hold (a preview, a recording) writes `session_id`.
44
+ */
45
+ /**
46
+ * How long a claim may wait for its session to be created before the idle
47
+ * sweeper frees it: a create that never finished (the hub restarted during
48
+ * it, or it hung). Well past a create's own budget: the phone's wait
49
+ * (`deviceAvailabilityTimeoutMs`) comes before the claim, and a first
50
+ * UiAutomator2 or WebDriverAgent install fits in a few minutes.
51
+ */
52
+ exports.PENDING_CLAIM_TIMEOUT_MS = 10 * 60000;
53
+ /** A claim whose session is still being created. */
54
+ function isPendingClaim(device) {
55
+ return device.claimedAt != null && device.claimSessionId == null;
56
+ }
57
+ /** A pending claim older than PENDING_CLAIM_TIMEOUT_MS. */
58
+ function pendingClaimExpired(device, now) {
59
+ return isPendingClaim(device) && now - device.claimedAt > exports.PENDING_CLAIM_TIMEOUT_MS;
60
+ }
61
+ /** Prisma `where` (beside udid and host) for the claim `ref` names. */
62
+ function claimWhere(ref) {
63
+ if ('sessionId' in ref) {
64
+ return {
65
+ OR: [{ claimSessionId: ref.sessionId }, { claimSessionId: null, session_id: ref.sessionId }],
66
+ };
67
+ }
68
+ if (ref.claimedAt == null)
69
+ return { claimSessionId: null, claimedAt: null, session_id: null };
70
+ return { claimSessionId: null, claimedAt: ref.claimedAt };
71
+ }
72
+ /** claimWhere as a predicate, for the in-memory store. */
73
+ function holdsClaim(device, ref) {
74
+ if ('sessionId' in ref) {
75
+ return (device.claimSessionId === ref.sessionId ||
76
+ (device.claimSessionId == null && device.session_id === ref.sessionId));
77
+ }
78
+ if (device.claimSessionId != null)
79
+ return false;
80
+ if (ref.claimedAt == null)
81
+ return device.claimedAt == null && device.session_id == null;
82
+ return device.claimedAt === ref.claimedAt;
83
+ }
84
+ /** No claim of this server's on the phone, pending or not. */
85
+ exports.NO_CLAIM = { claimSessionId: null, claimedAt: null };
86
+ function hasNoClaim(device) {
87
+ return device.claimSessionId == null && device.claimedAt == null;
88
+ }
89
+ /**
90
+ * Nothing holds the phone: no claim, no session or manual hold, and no node
91
+ * reporting it busy. Only then may a release clear `busy`.
92
+ */
93
+ exports.UNHELD = Object.assign(Object.assign({}, exports.NO_CLAIM), { session_id: null, nodeBusy: false });
94
+ function isUnheld(device) {
95
+ return hasNoClaim(device) && device.session_id == null && device.nodeBusy !== true;
96
+ }
97
+ /** What ending a claim resets: the claim, and the session's bookkeeping. */
98
+ exports.CLAIM_RESET = Object.assign(Object.assign({}, exports.NO_CLAIM), { session_id: null, lastCmdExecutedAt: null, sessionStartTime: 0, newCommandTimeout: null, sessionProgress: '', owningSessionId: null, lockedAt: null });
@@ -3,8 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.DISCOVERY_FIELDS = void 0;
6
+ exports.NODE_REPORT_FIELDS = exports.DISCOVERY_FIELDS = void 0;
7
7
  exports.pickDiscoveryFields = pickDiscoveryFields;
8
+ exports.pickNodeReportFields = pickNodeReportFields;
8
9
  exports.discoveryChanges = discoveryChanges;
9
10
  const lodash_1 = __importDefault(require("lodash"));
10
11
  /**
@@ -12,12 +13,21 @@ const lodash_1 = __importDefault(require("lodash"));
12
13
  *
13
14
  * The periodic device sync reads every row, spends seconds in adb or simctl,
14
15
  * then writes each phone back. For a phone it already knows it may write only
15
- * what discovery itself observes (`discovery`). Every `runtime` column has
16
+ * what discovery itself observes (`discovery`). Every other column has
16
17
  * another writer: a session's lock, a preview's hold, an admin's block,
17
18
  * reservation or team, a counter, the health check. A sync that read the row
18
19
  * before one of those writes would put the old value back. That erased a live
19
20
  * preview hold on the lab, and could hand a phone in a session to a second one.
20
21
  *
22
+ * A hub also stores its nodes' phones, from each node's report
23
+ * (`POST /xenon/api/register`, NODE_REPORT_FIELDS). The report carries what
24
+ * the node observes of its own phone: `discovery`, and `observed` (its health,
25
+ * which only the server driving the phone can check). It never writes a
26
+ * `runtime` column: the hub's claim for a session, and the settings the hub
27
+ * owns for the phone (team, tags, reservation, block). Whether the node says
28
+ * the phone is busy goes to `nodeBusy` (`report`), never to `busy`
29
+ * (deviceClaims.ts).
30
+ *
21
31
  * Typed over every column, so a new column won't compile until it is placed.
22
32
  */
23
33
  const OWNER = {
@@ -59,8 +69,9 @@ const OWNER = {
59
69
  newCommandTimeout: 'runtime',
60
70
  owningSessionId: 'runtime',
61
71
  lockedAt: 'runtime',
72
+ claimSessionId: 'runtime',
73
+ claimedAt: 'runtime',
62
74
  userBlocked: 'runtime',
63
- offline: 'runtime',
64
75
  reservationReason: 'runtime',
65
76
  reservedBy: 'runtime',
66
77
  reservedUntil: 'runtime',
@@ -68,14 +79,16 @@ const OWNER = {
68
79
  tags: 'runtime',
69
80
  totalUtilizationTimeMilliSec: 'runtime',
70
81
  total_session_count: 'runtime',
71
- totalHealedCount: 'runtime',
72
82
  dashboard_link: 'runtime',
73
- healthStatus: 'runtime',
74
- healthCheckError: 'runtime',
75
- lastHealthCheckAt: 'runtime',
76
- batteryLevel: 'runtime',
77
- thermalStatus: 'runtime',
78
- storageFree: 'runtime',
83
+ offline: 'observed',
84
+ totalHealedCount: 'observed',
85
+ healthStatus: 'observed',
86
+ healthCheckError: 'observed',
87
+ lastHealthCheckAt: 'observed',
88
+ batteryLevel: 'observed',
89
+ thermalStatus: 'observed',
90
+ storageFree: 'observed',
91
+ nodeBusy: 'report',
79
92
  };
80
93
  /** The columns a sync may write to a phone it already knows. */
81
94
  exports.DISCOVERY_FIELDS = new Set(Object.keys(OWNER).filter((column) => OWNER[column] === 'discovery'));
@@ -83,6 +96,15 @@ exports.DISCOVERY_FIELDS = new Set(Object.keys(OWNER).filter((column) => OWNER[c
83
96
  function pickDiscoveryFields(data) {
84
97
  return lodash_1.default.pickBy(data, (_value, key) => exports.DISCOVERY_FIELDS.has(key));
85
98
  }
99
+ /**
100
+ * The columns a hub takes from a node's report of a phone: what the node
101
+ * observes of it. Not `nodeBusy`, which the hub sets from the report's `busy`.
102
+ */
103
+ exports.NODE_REPORT_FIELDS = new Set(Object.keys(OWNER).filter((column) => OWNER[column] === 'discovery' || OWNER[column] === 'observed'));
104
+ /** Only the columns of `data` a hub takes from a node's report. */
105
+ function pickNodeReportFields(data) {
106
+ return lodash_1.default.pickBy(data, (_value, key) => exports.NODE_REPORT_FIELDS.has(key));
107
+ }
86
108
  /**
87
109
  * What a sync writes to a phone it already knows: the discovery columns whose
88
110
  * value differs from `known`, the row the sync started from. Discovery hands
@@ -52,6 +52,7 @@ const semver = __importStar(require("semver"));
52
52
  const logger_1 = __importDefault(require("../logger"));
53
53
  const deviceColumns_1 = require("./deviceColumns");
54
54
  const deviceFieldOwners_1 = require("./deviceFieldOwners");
55
+ const deviceClaims_1 = require("./deviceClaims");
55
56
  /** Logged once per key, so a chatty node doesn't flood the log. */
56
57
  const droppedDeviceKeys = new Set();
57
58
  class PrismaDeviceStore {
@@ -75,8 +76,8 @@ class PrismaDeviceStore {
75
76
  }
76
77
  }
77
78
  toIDevice(device) {
78
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
79
- return Object.assign(Object.assign({}, device), { cloud: this.safeParse(device.cloud, 'cloud', device.udid), capability: this.safeParse(device.capability, 'capability', device.udid), chromeDriverPath: this.safeParse(device.chromeDriverPath, 'chromeDriverPath', device.udid), tags: this.safeParse(device.tags, 'tags', device.udid), platform: (device.platform || 'android'), name: device.name || 'unknown', state: device.state || 'available', sdk: device.sdk || 'unknown', deviceType: device.deviceType || 'real', busy: (_a = device.busy) !== null && _a !== void 0 ? _a : false, userBlocked: (_b = device.userBlocked) !== null && _b !== void 0 ? _b : false, realDevice: (_c = device.realDevice) !== null && _c !== void 0 ? _c : true, lastHealthCheckAt: (_d = device.lastHealthCheckAt) !== null && _d !== void 0 ? _d : undefined, healthStatus: (_e = device.healthStatus) !== null && _e !== void 0 ? _e : 'Healthy', healthCheckError: (_f = device.healthCheckError) !== null && _f !== void 0 ? _f : undefined, batteryLevel: (_g = device.batteryLevel) !== null && _g !== void 0 ? _g : undefined, thermalStatus: (_h = device.thermalStatus) !== null && _h !== void 0 ? _h : undefined, storageFree: (_j = device.storageFree) !== null && _j !== void 0 ? _j : undefined, sessionProgress: (_k = device.sessionProgress) !== null && _k !== void 0 ? _k : '', totalHealedCount: (_l = device.totalHealedCount) !== null && _l !== void 0 ? _l : 0 });
79
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
80
+ return Object.assign(Object.assign({}, device), { cloud: this.safeParse(device.cloud, 'cloud', device.udid), capability: this.safeParse(device.capability, 'capability', device.udid), chromeDriverPath: this.safeParse(device.chromeDriverPath, 'chromeDriverPath', device.udid), tags: this.safeParse(device.tags, 'tags', device.udid), platform: (device.platform || 'android'), name: device.name || 'unknown', state: device.state || 'available', sdk: device.sdk || 'unknown', deviceType: device.deviceType || 'real', busy: (_a = device.busy) !== null && _a !== void 0 ? _a : false, userBlocked: (_b = device.userBlocked) !== null && _b !== void 0 ? _b : false, realDevice: (_c = device.realDevice) !== null && _c !== void 0 ? _c : true, lastHealthCheckAt: (_d = device.lastHealthCheckAt) !== null && _d !== void 0 ? _d : undefined, healthStatus: (_e = device.healthStatus) !== null && _e !== void 0 ? _e : 'Healthy', healthCheckError: (_f = device.healthCheckError) !== null && _f !== void 0 ? _f : undefined, batteryLevel: (_g = device.batteryLevel) !== null && _g !== void 0 ? _g : undefined, thermalStatus: (_h = device.thermalStatus) !== null && _h !== void 0 ? _h : undefined, storageFree: (_j = device.storageFree) !== null && _j !== void 0 ? _j : undefined, sessionProgress: (_k = device.sessionProgress) !== null && _k !== void 0 ? _k : '', totalHealedCount: (_l = device.totalHealedCount) !== null && _l !== void 0 ? _l : 0, nodeBusy: (_m = device.nodeBusy) !== null && _m !== void 0 ? _m : false });
80
81
  }
81
82
  fromIDevice(device) {
82
83
  const data = Object.assign({}, device);
@@ -208,17 +209,12 @@ class PrismaDeviceStore {
208
209
  logger_1.default.debug(`[PrismaStore] Update device ${udid} at ${host}: ${result.count} records affected`);
209
210
  });
210
211
  }
211
- updateDevices(filter, updateFn) {
212
+ touchSession(sessionId, at) {
212
213
  return __awaiter(this, void 0, void 0, function* () {
213
- const devices = yield this.prisma.device.findMany({ where: filter });
214
- for (const d of devices) {
215
- const idv = this.toIDevice(d);
216
- updateFn(idv);
217
- yield this.prisma.device.update({
218
- where: { udid_host: { udid: d.udid, host: d.host } },
219
- data: this.fromIDevice(idv),
220
- });
221
- }
214
+ yield this.prisma.device.updateMany({
215
+ where: { session_id: sessionId },
216
+ data: { lastCmdExecutedAt: at },
217
+ });
222
218
  });
223
219
  }
224
220
  addDevices(devices_1) {
@@ -232,31 +228,63 @@ class PrismaDeviceStore {
232
228
  const known = new Set(existing.map((d) => `${d.udid}@${d.host}`));
233
229
  const added = [];
234
230
  for (const device of devices) {
235
- const data = this.fromIDevice(device);
236
- // Use upsert to avoid race conditions and unique constraint errors. A
237
- // phone already here gets only its discovery columns (AddDevicesOptions).
238
- const d = yield this.prisma.device.upsert({
239
- where: { udid_host: { udid: device.udid, host: device.host } },
240
- update: options.mirror ? data : (0, deviceFieldOwners_1.pickDiscoveryFields)(data),
241
- create: data,
242
- });
231
+ const d = options.nodeReport
232
+ ? yield this.addNodeReport(device)
233
+ : yield this.prisma.device.upsert({
234
+ // Use upsert to avoid race conditions and unique constraint errors. A
235
+ // phone already here gets only its discovery columns (AddDevicesOptions).
236
+ where: { udid_host: { udid: device.udid, host: device.host } },
237
+ update: (0, deviceFieldOwners_1.pickDiscoveryFields)(this.fromIDevice(device)),
238
+ create: this.fromIDevice(device),
239
+ });
243
240
  if (!known.has(`${device.udid}@${device.host}`))
244
241
  added.push(this.toIDevice(d));
245
242
  }
246
243
  return added;
247
244
  });
248
245
  }
249
- removeDevices(filter) {
246
+ /**
247
+ * A node's report of one phone (AddDevicesOptions.nodeReport). The node's
248
+ * `busy` goes to `nodeBusy`. Busy there makes the phone busy here; free
249
+ * there frees it only where this hub holds no claim, in one conditional
250
+ * update, so a report the node sent before the hub claimed the phone can't
251
+ * undo the claim.
252
+ */
253
+ addNodeReport(device) {
250
254
  return __awaiter(this, void 0, void 0, function* () {
255
+ const { udid, host } = device;
256
+ const nodeBusy = device.busy === true;
257
+ const reported = Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(this.fromIDevice(device))), { nodeBusy });
258
+ const row = yield this.prisma.device.upsert({
259
+ where: { udid_host: { udid, host } },
260
+ update: nodeBusy ? Object.assign(Object.assign({}, reported), { busy: true }) : reported,
261
+ create: Object.assign(Object.assign({}, reported), { udid, host, busy: nodeBusy }),
262
+ });
263
+ if (nodeBusy)
264
+ return row;
265
+ // Nothing of the hub's may hold it either: a claim, or a hold such as a
266
+ // preview or a recording (session_id), exactly as a release checks.
267
+ yield this.prisma.device.updateMany({
268
+ where: Object.assign({ udid, host, busy: true }, deviceClaims_1.UNHELD),
269
+ data: { busy: false },
270
+ });
271
+ return row;
272
+ });
273
+ }
274
+ removeDevices(filter_1) {
275
+ return __awaiter(this, arguments, void 0, function* (filter, options = {}) {
251
276
  // Principal Fix: Support partial host matching (e.g., "192.168.0.100" should match "http://192.168.0.100:4723")
252
277
  // This is necessary because the device tracker passes the raw IP, not the full URL.
278
+ // `exactHost` turns it off, for a caller that names rows by their own host.
253
279
  const whereClause = {};
254
280
  if (filter.udid) {
255
281
  whereClause.udid = filter.udid;
256
282
  }
257
283
  if (filter.host) {
258
284
  // Use Prisma's `contains` for partial matching if host doesn't look like a full URL
259
- if (filter.host.startsWith('http://') || filter.host.startsWith('https://')) {
285
+ if (options.exactHost ||
286
+ filter.host.startsWith('http://') ||
287
+ filter.host.startsWith('https://')) {
260
288
  whereClause.host = filter.host;
261
289
  }
262
290
  else {
@@ -266,9 +294,9 @@ class PrismaDeviceStore {
266
294
  yield this.prisma.device.deleteMany({ where: whereClause });
267
295
  });
268
296
  }
269
- clearStorage() {
297
+ clearStorage(onlyHosts) {
270
298
  return __awaiter(this, void 0, void 0, function* () {
271
- yield this.prisma.device.deleteMany();
299
+ yield this.prisma.device.deleteMany(onlyHosts ? { where: { host: { in: [...onlyHosts] } } } : undefined);
272
300
  });
273
301
  }
274
302
  findDevice(filter) {
@@ -283,8 +311,8 @@ class PrismaDeviceStore {
283
311
  return devices.map((d) => this.toIDevice(d));
284
312
  });
285
313
  }
286
- findAndLockDevice(filterOptions) {
287
- return __awaiter(this, void 0, void 0, function* () {
314
+ findAndLockDevice(filterOptions_1) {
315
+ return __awaiter(this, arguments, void 0, function* (filterOptions, options = {}) {
288
316
  // Phase 2: exclude devices held by an active lease.
289
317
  const activeLeases = yield this.prisma.lease.findMany({
290
318
  where: { status: 'active' },
@@ -298,6 +326,7 @@ class PrismaDeviceStore {
298
326
  if (blockedKeys.has(`${device.udid}@${device.host}`)) {
299
327
  continue;
300
328
  }
329
+ const now = Date.now();
301
330
  const result = yield this.prisma.device.updateMany({
302
331
  where: {
303
332
  udid: device.udid,
@@ -305,11 +334,7 @@ class PrismaDeviceStore {
305
334
  busy: false, // Double check it's still free
306
335
  offline: false,
307
336
  },
308
- data: {
309
- busy: true,
310
- lastCmdExecutedAt: Date.now(),
311
- sessionStartTime: Date.now(),
312
- },
337
+ data: Object.assign({ busy: true, lastCmdExecutedAt: now, sessionStartTime: now }, (options.claim ? { claimedAt: now, claimSessionId: null } : {})),
313
338
  });
314
339
  if (result.count === 1) {
315
340
  logger_1.default.info(`[PrismaStore] Successfully locked device ${device.udid} atomically`);
@@ -323,6 +348,44 @@ class PrismaDeviceStore {
323
348
  return null;
324
349
  });
325
350
  }
351
+ claimForSession(udid, host, claimedAt, sessionId, update) {
352
+ return __awaiter(this, void 0, void 0, function* () {
353
+ // The pending claim this phone was allocated with, or none left at all
354
+ // (released while the session was created, and not taken since).
355
+ const ours = claimedAt == null ? [{ claimedAt: null }] : [{ claimedAt }, { claimedAt: null }];
356
+ const result = yield this.prisma.device.updateMany({
357
+ where: { udid, host, claimSessionId: null, OR: ours },
358
+ data: Object.assign(Object.assign({}, this.fromIDevice(update)), { busy: true, claimSessionId: sessionId, claimedAt: claimedAt !== null && claimedAt !== void 0 ? claimedAt : Date.now() }),
359
+ });
360
+ return result.count > 0;
361
+ });
362
+ }
363
+ releaseClaim(udid, host, ref, update) {
364
+ return __awaiter(this, void 0, void 0, function* () {
365
+ const ended = yield this.prisma.device.updateMany({
366
+ where: Object.assign({ udid, host }, (0, deviceClaims_1.claimWhere)(ref)),
367
+ data: Object.assign(Object.assign({}, this.fromIDevice(update)), deviceClaims_1.CLAIM_RESET),
368
+ });
369
+ if (ended.count === 0)
370
+ return false;
371
+ // Not `busy: false` with the step above: a node reporting the phone busy,
372
+ // or a hold taken since, keeps it busy.
373
+ yield this.prisma.device.updateMany({
374
+ where: Object.assign({ udid, host, busy: true }, deviceClaims_1.UNHELD),
375
+ data: { busy: false },
376
+ });
377
+ return true;
378
+ });
379
+ }
380
+ releaseLeaseLock(udid, host) {
381
+ return __awaiter(this, void 0, void 0, function* () {
382
+ const cleared = yield this.prisma.device.updateMany({
383
+ where: Object.assign({ udid, host, busy: true }, deviceClaims_1.UNHELD),
384
+ data: { busy: false },
385
+ });
386
+ return cleared.count > 0;
387
+ });
388
+ }
326
389
  resetMetrics() {
327
390
  return __awaiter(this, void 0, void 0, function* () {
328
391
  yield this.prisma.device.updateMany({
@@ -77,6 +77,7 @@ const Devices_1 = __importDefault(require("./cloud/Devices"));
77
77
  const DeviceLockManager_1 = require("./android/DeviceLockManager");
78
78
  const androidIdentity_1 = require("./android/androidIdentity");
79
79
  const deviceIdentity_1 = require("./deviceIdentity");
80
+ const localDeviceHosts_1 = require("./localDeviceHosts");
80
81
  const PluginContext_1 = require("../PluginContext");
81
82
  const typedi_2 = require("typedi");
82
83
  let AndroidDeviceManager = class AndroidDeviceManager {
@@ -196,7 +197,11 @@ let AndroidDeviceManager = class AndroidDeviceManager {
196
197
  adbInstance.adbRemoteHost === null
197
198
  ? this.pluginArgs.bindHostOrIp
198
199
  : adbInstance.adbRemoteHost;
199
- const existingDevice = existingDeviceDetails.find((dev) => dev.udid === device.udid && dev.host.includes(this.pluginArgs.bindHostOrIp));
200
+ // This server's own row for the phone: the exact host deviceInfo
201
+ // would write. A node on this machine shares the IP, so its row
202
+ // for the same phone must not be taken for ours.
203
+ const ownHost = (0, localDeviceHosts_1.androidDeviceHost)(this.pluginArgs, this.hostPort, adbInstance);
204
+ const existingDevice = existingDeviceDetails.find((dev) => dev.udid === device.udid && dev.host === ownHost);
200
205
  if (existingDevice) {
201
206
  if (device.state === 'device') {
202
207
  logger_1.default.debug(`Android Device details for ${device.udid} already available and online`);
@@ -263,16 +268,7 @@ let AndroidDeviceManager = class AndroidDeviceManager {
263
268
  }
264
269
  // Optional: a device that can't say still registers, as before.
265
270
  const identity = yield this.getIdentity(adbInstance, device.udid, realDevice);
266
- let host;
267
- if (adbInstance.adbHost != null) {
268
- host = `http://${adbInstance.adbHost}:${adbInstance.adbPort}`;
269
- }
270
- else if (pluginArgs.remoteMachineProxyIP !== undefined) {
271
- host = `http://${pluginArgs.remoteMachineProxyIP}:${hostPort}`;
272
- }
273
- else {
274
- host = `http://${pluginArgs.bindHostOrIp}:${hostPort}`;
275
- }
271
+ const host = (0, localDeviceHosts_1.androidDeviceHost)(pluginArgs, hostPort, adbInstance);
276
272
  return Object.assign({ adbRemoteHost: (_a = adbInstance.adbRemoteHost) !== null && _a !== void 0 ? _a : undefined, adbPort: adbInstance.adbPort, systemPort, sdk: sdk !== null && sdk !== void 0 ? sdk : 'unknown', realDevice, name: name !== null && name !== void 0 ? name : 'unknown', busy: false, state: device.state, udid: device.udid, platform: 'android', deviceType: realDevice ? 'real' : 'emulator', host, totalUtilizationTimeMilliSec: totalUtilizationTimeMilliSec, sessionStartTime: 0, userBlocked: false, ip: (0, networkAddresses_1.sanitizeDeviceNetworkIp)(yield this.getDeviceIp(adbInstance, device.udid)), cpuArchitecture: yield this.getCpuArchitecture(adbInstance, device.udid) }, identity);
277
273
  });
278
274
  }
@@ -477,9 +473,7 @@ let AndroidDeviceManager = class AndroidDeviceManager {
477
473
  const adbRemote = pluginArgs.adbRemote;
478
474
  if (adbRemote !== undefined && adbRemote.length > 0) {
479
475
  const promises = adbRemote.map((value) => __awaiter(this, void 0, void 0, function* () {
480
- const adbRemoteValue = value.split(':');
481
- const adbHost = adbRemoteValue[0];
482
- const adbPort = parseInt(adbRemoteValue[1]) || 5037;
476
+ const { adbHost, adbPort } = (0, localDeviceHosts_1.parseAdbRemote)(value);
483
477
  const cloneAdb = originalADB.clone({
484
478
  remoteAdbHost: adbHost,
485
479
  adbPort,
@@ -577,10 +571,14 @@ let AndroidDeviceManager = class AndroidDeviceManager {
577
571
  }
578
572
  onDeviceRemoved(device, pluginArgs) {
579
573
  return __awaiter(this, void 0, void 0, function* () {
574
+ // The host onDeviceAdded files the phone under (deviceInfo with the local
575
+ // adb). Exact: a bare IP is matched as a substring by the store and by
576
+ // the hub, and would also take another server's row with this udid.
580
577
  const clonedDevice = {
581
578
  udid: device['id'],
582
- host: pluginArgs.bindHostOrIp,
579
+ host: (0, localDeviceHosts_1.androidDeviceHost)(pluginArgs, this.hostPort),
583
580
  state: device.type,
581
+ nodeId: this.nodeId,
584
582
  };
585
583
  if (pluginArgs.hub != undefined) {
586
584
  const nodeDevices = new NodeDevices_1.default(pluginArgs.hub, {
@@ -63,6 +63,9 @@ const schedule = __importStar(require("node-schedule"));
63
63
  const web_config_service_1 = require("../data-service/web-config-service");
64
64
  const SessionManager_1 = require("../sessions/SessionManager");
65
65
  const EventBus_1 = require("../services/EventBus");
66
+ const deviceClaims_1 = require("../data-service/deviceClaims");
67
+ const PluginContext_1 = require("../PluginContext");
68
+ const localDeviceHosts_1 = require("./localDeviceHosts");
66
69
  let HealthMonitorService = class HealthMonitorService {
67
70
  constructor(webConfigService) {
68
71
  this.webConfigService = webConfigService;
@@ -145,14 +148,26 @@ let HealthMonitorService = class HealthMonitorService {
145
148
  const devices = yield store.getAllDevices();
146
149
  const manager = typedi_1.Container.get(index_1.XenonManager);
147
150
  const instances = yield manager.deviceInstances();
151
+ const context = typedi_1.Container.get(PluginContext_1.PluginContext);
152
+ const local = (0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port);
148
153
  for (const device of devices) {
149
- // Only check devices managed by this node
150
- if (device.cloud)
154
+ // Only this server's own phones. A hub keeps its nodes' phones in the
155
+ // same table; its adb can't reach them, so each came back unhealthy,
156
+ // was written over the node's report, "recovered" and, when busy with
157
+ // a session not in this hub's memory, reclaimed. Their node checks them.
158
+ if (device.cloud || !(0, localDeviceHosts_1.isOwnDevice)(local, context.nodeId, device))
151
159
  continue;
152
160
  // Principal Intelligence: Health monitor must detect "Zombie Busy" devices.
153
161
  // A device is a Zombie if it's marked busy in DB but NOT found in Hub's session map
154
162
  // AND not currently streaming to a manual viewer.
155
163
  if (device.busy) {
164
+ // Not a zombie: a session still being created on it (its session is
165
+ // in no map yet; the claim has its own timeout), or, on a hub, a
166
+ // node reporting it busy there (deviceClaims.ts).
167
+ if (((0, deviceClaims_1.isPendingClaim)(device) && !(0, deviceClaims_1.pendingClaimExpired)(device, Date.now())) ||
168
+ device.nodeBusy) {
169
+ continue;
170
+ }
156
171
  const hasActiveSession = SessionManager_1.SESSION_MANAGER.isValidSession(device.session_id || '');
157
172
  const isManualStream = (_a = device.session_id) === null || _a === void 0 ? void 0 : _a.startsWith('manual_');
158
173
  // NEW: Check for active manual streams (dashboard viewers)
@@ -99,7 +99,11 @@ let IOSDeviceManager = class IOSDeviceManager {
99
99
  result.screenHeight = String(streamStatus.screenHeight);
100
100
  }
101
101
  else {
102
- const storeDevice = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid: device.udid });
102
+ // This phone's own row: another server's for the same udid is not it.
103
+ const storeDevice = yield device_store_1.DeviceStoreFactory.getStore().findDevice({
104
+ udid: device.udid,
105
+ host: device.host,
106
+ });
103
107
  if (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.screenWidth) {
104
108
  result.screenWidth = storeDevice.screenWidth;
105
109
  result.screenHeight = storeDevice.screenHeight;