@xenon-device-management/xenon 2.13.2 → 2.14.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 (209) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-BXruClDk.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-qTS4LPLC.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BoGhlI7F.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-Cr17PdDv.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-Bvn08svv.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-DDSO8aDf.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-B-Qn2X3T.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-Cg9V5JYU.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-BTUs9VTW.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-DYcfxITK.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-Zy7suhT5.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-CWYmycoe.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-io9fwa98.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Bdf_uIWl.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-Bce5SJob.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CPqtVpm3.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-BgZan0H9.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-B27ckB31.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-DOF8ITCz.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-eFG5KTFS.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DlC3_27j.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BuXpPfKo.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-fIW8owMI.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-WNjmrF-a.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-BdTh1Sq7.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-B4BKlsqk.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-zJU80_EF.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-5Uwquo0h.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-BtUprDsZ.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-DGSbZI3u.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-WG6l7BgD.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DnudqFFo.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-DI1fGIyj.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-HDQVe1gi.js → index-DJGc1M2f.js} +1 -1
  40. package/lib/public/assets/{input-BjDRKCea.js → input-Cm0tmSoc.js} +1 -1
  41. package/lib/public/assets/{line-chart-C0zdgd0v.js → line-chart-D2DyJCml.js} +1 -1
  42. package/lib/public/assets/{list-checks-BdW5CjM8.js → list-checks-UWf1GY99.js} +1 -1
  43. package/lib/public/assets/{lock-Dy3L4I8E.js → lock-CCJv9m_I.js} +1 -1
  44. package/lib/public/assets/{login-BHbY9CLG.js → login-f8vOVW-U.js} +1 -1
  45. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  46. package/lib/public/assets/{monitor-8xjkeHHi.js → monitor-B2jDgwm7.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CXdKMXQ8.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  48. package/lib/public/assets/{network-Cj8JOKON.js → network-ebT86qoi.js} +1 -1
  49. package/lib/public/assets/{overview-CTg7F0im.js → overview-C0aykVrd.js} +1 -1
  50. package/lib/public/assets/{page-header--aTyJXq6.js → page-header-BjzgWz-T.js} +1 -1
  51. package/lib/public/assets/{play-B-FodgHV.js → play-DLG9t8FU.js} +1 -1
  52. package/lib/public/assets/{plus-CsIk10Cq.js → plus-nBFSMu_X.js} +1 -1
  53. package/lib/public/assets/{profile-page-DkFKkMfl.js → profile-page-CSl0UYlv.js} +1 -1
  54. package/lib/public/assets/{reset-password-I2-zqLlI.js → reset-password-LeOuHqmB.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJTVCJX7.js → runbook-page-CMMeQml9.js} +1 -1
  56. package/lib/public/assets/{select-DvCSv25z.js → select-88jHcl-Z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BLVfMJs_.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-oqadRokn.js → selector-health-page-B4rA1Xkv.js} +2 -2
  59. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  60. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  61. package/lib/public/assets/{stat-tile-DV9Ix-4O.js → stat-tile-BV98e_Sv.js} +1 -1
  62. package/lib/public/assets/{tablet-6b_SEN2X.js → tablet-BiAsnFDj.js} +1 -1
  63. package/lib/public/assets/{teams-BjZyfuHh.js → teams-B4-jrr-1.js} +1 -1
  64. package/lib/public/assets/{trash-2-CTZpPSdd.js → trash-2-C5RSuX0e.js} +1 -1
  65. package/lib/public/assets/{upload-P3XzwQUb.js → upload-BG7_rMXK.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-1cOCMf-z.js → use-builds-data-J-riQYyC.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DgF9w088.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  68. package/lib/public/assets/{users-DMF9Mk5H.js → users-CFba5TSy.js} +1 -1
  69. package/lib/public/assets/{users-CiNTKUiz.js → users-Cp0beJm3.js} +1 -1
  70. package/lib/public/assets/{video-off-CHp1KU_T.js → video-off-DDsEISjV.js} +1 -1
  71. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  72. package/lib/public/assets/{zap-DRz0tDq5.js → zap-l4VaXzq8.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +19 -21
  75. package/lib/src/XenonCapabilityManager.js +22 -5
  76. package/lib/src/app/openapi/control.yaml +71 -20
  77. package/lib/src/app/openapi/grid.yaml +23 -5
  78. package/lib/src/app/openapi/platform.yaml +136 -28
  79. package/lib/src/app/routers/control.js +12 -2
  80. package/lib/src/app/routers/dashboard.js +29 -1
  81. package/lib/src/app/routers/grid.js +32 -10
  82. package/lib/src/app/routers/streamType.js +7 -2
  83. package/lib/src/app/routers/webhook.js +8 -4
  84. package/lib/src/config.js +50 -5
  85. package/lib/src/dashboard/commands.js +185 -141
  86. package/lib/src/dashboard/event-manager.js +39 -6
  87. package/lib/src/data-service/appleFamily.js +43 -0
  88. package/lib/src/data-service/db.js +11 -0
  89. package/lib/src/data-service/device-service.js +6 -1
  90. package/lib/src/data-service/device-store.js +62 -2
  91. package/lib/src/data-service/deviceClaims.js +37 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  93. package/lib/src/data-service/deviceSettings.js +79 -0
  94. package/lib/src/data-service/prisma-store.js +150 -8
  95. package/lib/src/data-service/web-config-service.js +43 -92
  96. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  97. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  98. package/lib/src/device-managers/shellCommands.js +96 -0
  99. package/lib/src/device-utils.js +96 -40
  100. package/lib/src/generated/client/edge.js +17 -3
  101. package/lib/src/generated/client/index-browser.js +14 -0
  102. package/lib/src/generated/client/index.d.ts +2054 -379
  103. package/lib/src/generated/client/index.js +17 -3
  104. package/lib/src/generated/client/package.json +1 -1
  105. package/lib/src/generated/client/schema.prisma +35 -7
  106. package/lib/src/generated/client/wasm.js +14 -0
  107. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  108. package/lib/src/interceptors/xenonScripts.js +55 -0
  109. package/lib/src/interfaces/IPluginArgs.js +1 -2
  110. package/lib/src/plugin.js +15 -1
  111. package/lib/src/prisma.js +1 -1
  112. package/lib/src/scripts/database-check.js +124 -0
  113. package/lib/src/services/AIService.js +99 -0
  114. package/lib/src/services/CleanupService.js +6 -1
  115. package/lib/src/services/InterceptorService.js +70 -13
  116. package/lib/src/services/NetworkConditioningService.js +85 -88
  117. package/lib/src/services/NotificationService.js +36 -29
  118. package/lib/src/services/OrphanSweeper.js +5 -0
  119. package/lib/src/services/PortAllocator.js +5 -0
  120. package/lib/src/services/ServerManager.js +94 -8
  121. package/lib/src/services/SessionLifecycleService.js +47 -49
  122. package/lib/src/services/ShutdownCoordinator.js +33 -0
  123. package/lib/src/services/TeamService.js +20 -3
  124. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  125. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  126. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  127. package/lib/src/services/network/AndroidRadios.js +71 -0
  128. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  129. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  130. package/lib/src/services/network/adbForPhone.js +60 -0
  131. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  132. package/lib/src/services/network/xenonProxy.js +107 -0
  133. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  134. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  135. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  136. package/lib/src/services/omni-vision/screenScale.js +68 -0
  137. package/lib/src/services/recording/concurrency-gate.js +12 -3
  138. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  139. package/lib/src/services/settings/labSettings.js +149 -0
  140. package/lib/src/services/webhookEvents.js +173 -0
  141. package/lib/test/helpers/fake-adb.js +58 -0
  142. package/lib/test/helpers/fake-device-table.js +63 -24
  143. package/lib/test/helpers/scratch-database.js +1 -0
  144. package/lib/test/unit/TeamService.test.js +3 -0
  145. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  146. package/lib/test/unit/apple-family.spec.js +36 -0
  147. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  148. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  149. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  150. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  151. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  152. package/lib/test/unit/database-check.spec.js +108 -0
  153. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  154. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  155. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  156. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  157. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  158. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  159. package/lib/test/unit/emulator-boot.spec.js +91 -0
  160. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  161. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  162. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  163. package/lib/test/unit/json-logging-option.spec.js +74 -0
  164. package/lib/test/unit/lab-settings.spec.js +218 -0
  165. package/lib/test/unit/max-sessions.spec.js +240 -0
  166. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  167. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  168. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  169. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  170. package/lib/test/unit/omni-vision.spec.js +1 -1
  171. package/lib/test/unit/partial-config-file.spec.js +14 -0
  172. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  173. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  174. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  175. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  176. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  177. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  178. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  179. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  180. package/lib/test/unit/plugin.spec.js +3 -3
  181. package/lib/test/unit/recording-options.spec.js +181 -0
  182. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  183. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  184. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  185. package/lib/test/unit/shell-commands.spec.js +133 -0
  186. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  187. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  188. package/lib/test/unit/stream-status-type.spec.js +4 -0
  189. package/lib/test/unit/vision-assertion.spec.js +118 -0
  190. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  191. package/lib/test/unit/web-config-service.spec.js +79 -0
  192. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  193. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  194. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  195. package/lib/tsconfig.tsbuildinfo +1 -1
  196. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  197. package/package.json +1 -1
  198. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  199. package/prisma/schema.prisma +35 -7
  200. package/schema.json +19 -21
  201. package/scripts/generate-types-from-schema.js +1 -2
  202. package/scripts/lib/always-present-plugin-args.js +4 -1
  203. package/lib/public/assets/DeviceMosaicView-8J0oYqMM.js +0 -2
  204. package/lib/public/assets/device-explorer-C-KnhMHI.js +0 -279
  205. package/lib/public/assets/maintenance-settings-zDVlOMVK.js +0 -6
  206. package/lib/public/assets/recording-group-store-WVM3hoXn.js +0 -26
  207. package/lib/public/assets/session-detail-page-CGn55Pvl.js +0 -4
  208. package/lib/public/assets/settings-MsVU2ukE.js +0 -6
  209. package/lib/public/assets/webhook-settings-DiS8geIm.js +0 -1
@@ -1483,13 +1483,20 @@ paths:
1483
1483
  refused, and so is a command that fails, **with `200`** and an `error` field instead of
1484
1484
  `output` (the dashboard's terminal prints either).
1485
1485
 
1486
- - **Android** (`adb shell`): commands starting with `ls`, `ps`, `top`, `dumpsys battery`,
1487
- `dumpsys wifi`, `dumpsys power`, `whoami`, `getprop`, `pm list packages`, `ip addr`, `cat
1488
- /proc/meminfo`, `cat /proc/cpuinfo`, `date`, `uptime` or `netstat`.
1486
+ A command must be one of the commands below, word for word, on one line. Where it takes
1487
+ arguments, they are plain words: letters, digits and `._/:=@%+,-`. Anything a shell would
1488
+ interpret (`;`, `|`, `&`, `$`, backticks, quotes, `<`, `>`) is refused.
1489
1489
 
1490
- - **iOS:** simulator commands `listapps`, `get_app_container`, `list`, `getenv` (through
1491
- `simctl`); go-ios commands `apps`, `info`, `syslog`, `list`, `deviceinfo`, `diagnostics`;
1492
- and `ls`, `ps`, `top`, `whoami`, `date`, `uptime`, `netstat`, `id`.
1490
+ - **Android** (`adb shell`): `ls`, `ps`, `top`, `getprop`, `pm list packages`, `ip addr`,
1491
+ `date` and `netstat`, with arguments; `dumpsys battery`, `dumpsys wifi`, `dumpsys power`,
1492
+ `whoami`, `cat /proc/meminfo`, `cat /proc/cpuinfo` and `uptime`, without.
1493
+
1494
+ - **iOS simulator:** `listapps`, and `get_app_container` and `getenv` with arguments
1495
+ (through `simctl`); `ls`, `ps`, `whoami`, `date`, `uptime` and `id` without arguments
1496
+ (spawned in the simulator).
1497
+
1498
+ - **Real iPhone** (go-ios, through the phone's tunnel): `apps` with arguments and `info`
1499
+ without. Xenon names the phone; a command may not pass `--udid`.
1493
1500
 
1494
1501
  Needs the `MEMBER` role and the `devices` scope. Refused with `409` while another user holds
1495
1502
  the device (their live preview or recording) or runs an Appium session on it; admins are
@@ -1639,14 +1646,14 @@ paths:
1639
1646
  operationId: scanDeviceScreen
1640
1647
  summary: Analyse the screen with OCR and AI
1641
1648
  description: >-
1642
- Screenshots the device, runs OCR over it (every word with its confidence and box) and asks
1643
- the configured AI provider for a qualitative read of the screen. No Appium session is
1644
- needed. Slow: allow tens of seconds.
1649
+ Screenshots the device, runs OCR over it (every word with its confidence and box, in the
1650
+ screenshot's pixels) and sends the screenshot to the configured AI provider for a short
1651
+ description of the screen. No Appium session is needed. Slow: allow tens of seconds.
1645
1652
 
1646
1653
 
1647
1654
  If the analysis itself fails (an empty screenshot, OCR failing), the answer is `500` with
1648
- `{ status: 'error', message }`. If only the AI step fails, the answer is `200` and
1649
- `ai_insights` is `null`.
1655
+ `{ status: 'error', message }`. If only the AI step fails, or no AI provider is
1656
+ configured, the answer is `200`, `ai_insights` is `null` and `ai_insights_error` says why.
1650
1657
 
1651
1658
 
1652
1659
  Open to watchers. Needs the `MEMBER` role; any scope will do.
@@ -1683,10 +1690,27 @@ paths:
1683
1690
  y0: 210
1684
1691
  x1: 188
1685
1692
  y1: 236
1686
- ai_insights:
1687
- summary: >-
1688
- A sign-in form with email and password fields and a disabled Sign in
1689
- button.
1693
+ ai_insights: >-
1694
+ A sign-in screen with empty email and password fields and a disabled Sign
1695
+ in button. No error or dialog is shown.
1696
+ noProvider:
1697
+ summary: No AI provider configured
1698
+ value:
1699
+ status: success
1700
+ value:
1701
+ timestamp: '2026-10-04T14:05:22.118Z'
1702
+ ocr:
1703
+ text: Sign in
1704
+ words:
1705
+ - text: Sign
1706
+ confidence: 96.2
1707
+ bbox:
1708
+ x0: 142
1709
+ y0: 210
1710
+ x1: 188
1711
+ y1: 236
1712
+ ai_insights: null
1713
+ ai_insights_error: No AI provider is configured
1690
1714
  '400':
1691
1715
  description: This server has no device manager for the platform, or the udid is malformed.
1692
1716
  content:
@@ -1745,8 +1769,9 @@ paths:
1745
1769
  Runs one of Xenon's AI locator strategies against a fresh screenshot, as a test would with
1746
1770
  `findElement`, without an Appium session.
1747
1771
 
1748
- - `-custom:ai-text`: OCR; every word containing `selector` (case-insensitive, confidence
1749
- above 60%).
1772
+ - `-custom:ai-text`: OCR; every place `selector` appears (case-insensitive), within a word
1773
+ or across neighbouring words on one line, so `Sign in` matches; each match read with
1774
+ confidence above 60%.
1750
1775
 
1751
1776
  - `-custom:ai-icon`: the configured AI provider looks for what `selector` describes; at most
1752
1777
  one match, a 40×40 box around the point it names.
@@ -1939,6 +1964,28 @@ paths:
1939
1964
  On a hub, for a node's phone, the request is forwarded to the node, which holds the phone by
1940
1965
  its own rules; on success the hub also marks the phone busy and held by you at once. A cloud
1941
1966
  provider's phone answers `501`.
1967
+
1968
+ One capture runs per Android device. A page that shows MJPEG (a browser that cannot decode
1969
+ H.264, which on plain http it cannot, or a page whose H.264 player failed) sends
1970
+ `player: mjpeg`: the server then starts the MJPEG capture, and first ends an H.264 capture
1971
+ already running for the device, so the two never run together.
1972
+ requestBody:
1973
+ required: false
1974
+ content:
1975
+ application/json:
1976
+ schema:
1977
+ type: object
1978
+ properties:
1979
+ player:
1980
+ type: string
1981
+ enum:
1982
+ - mjpeg
1983
+ description: >-
1984
+ Set to `mjpeg` when the page shows MJPEG. Left out, or any other value, the
1985
+ server chooses: H.264 for an Android device when the setting is on and the
1986
+ device is not being recorded, MJPEG otherwise.
1987
+ example:
1988
+ player: mjpeg
1942
1989
  responses:
1943
1990
  '200':
1944
1991
  description: The preview is running. `type` says which transport to use.
@@ -2968,10 +3015,14 @@ components:
2968
3015
  y1:
2969
3016
  type: number
2970
3017
  ai_insights:
2971
- type: object
3018
+ type: string
2972
3019
  nullable: true
2973
- additionalProperties: true
2974
- description: The AI provider's analysis; null if it was skipped or failed.
3020
+ description: >-
3021
+ The AI provider's description of the screen, in plain text; null when there is no
3022
+ AI provider or its call failed.
3023
+ ai_insights_error:
3024
+ type: string
3025
+ description: Why `ai_insights` is null. Present only then.
2975
3026
  ControlTestLocatorRequest:
2976
3027
  type: object
2977
3028
  required:
@@ -141,7 +141,8 @@ paths:
141
141
  description: |
142
142
  Sets `userBlocked` on a device, so no new session or lease is
143
143
  allocated to it. A session already running on it is not affected,
144
- and neither is `busy`.
144
+ and neither is `busy`. The device stays blocked when it disconnects
145
+ and comes back, through restarts too.
145
146
 
146
147
  Send both `udid` and `host`, exactly as `GET /api/devices` shows
147
148
  them: the device is the row of that udid on that host. Without
@@ -247,7 +248,9 @@ paths:
247
248
  Replaces the device's tag list with `tags` (it doesn't append). Tags
248
249
  are free-form labels, such as `flaky` or `lab-row-3`, that a lease
249
250
  can filter on. The row is chosen by exact `udid` and `host`; a pair
250
- that names no device changes nothing and still answers `200`.
251
+ that names no device is `404` (through 2.13 it changed nothing and
252
+ answered `200`). The device keeps its tags when it disconnects and
253
+ comes back, through restarts too.
251
254
 
252
255
  Needs role `ADMIN` and the `devices` scope.
253
256
  tags:
@@ -298,6 +301,14 @@ paths:
298
301
  $ref: '#/components/responses/Unauthorized'
299
302
  '403':
300
303
  $ref: '#/components/responses/Forbidden'
304
+ '404':
305
+ description: No device of that udid on that host.
306
+ content:
307
+ application/json:
308
+ schema:
309
+ $ref: '#/components/schemas/Error'
310
+ example:
311
+ error: Device not found
301
312
  '429':
302
313
  $ref: '#/components/responses/RateLimited'
303
314
  /api/device/{udid}/team:
@@ -307,10 +318,14 @@ paths:
307
318
  description: |
308
319
  Gives the device to a team, or with `teamId: null` returns it to the
309
320
  shared pool, which every member sees. A missing `teamId` counts as
310
- null. Every row with this udid changes (on a hub, each node that
321
+ null. Every device with this udid changes (on a hub, each node that
311
322
  reports it), and allocation and live dashboard events follow the new
312
323
  team at once.
313
324
 
325
+ A device keeps its team when it disconnects and comes back, through
326
+ restarts too, and this also moves one of this udid that isn't
327
+ connected now: a team that one belongs to can't be deleted.
328
+
314
329
  Needs role `ADMIN` and the `admin` scope.
315
330
  tags:
316
331
  - Devices
@@ -336,7 +351,7 @@ paths:
336
351
  example: clx9k2v0a0000qz8r4h7m3n2p
337
352
  responses:
338
353
  '200':
339
- description: The device's team changed. `updated` is how many rows changed.
354
+ description: The device's team changed. `updated` is how many devices changed, connected or not.
340
355
  content:
341
356
  application/json:
342
357
  schema:
@@ -432,7 +447,10 @@ paths:
432
447
  (`reservedByUserId`), who alone, or an admin, may release or extend
433
448
  it. A device already reserved by someone else, or running an Appium
434
449
  session, is refused with `409`. The same user under the same
435
- `reservedBy` may reserve it again, which restarts the clock.
450
+ `reservedBy` may reserve it again, which restarts the clock. The
451
+ reservation lasts until it ends even if the device disconnects or
452
+ the server restarts meanwhile; one that ended while the device was
453
+ away is not brought back.
436
454
 
437
455
  The device is the row of `udid` on `host`, so send both exactly as
438
456
  `GET /api/devices` shows them. No such row, or a device outside the
@@ -187,11 +187,17 @@ paths:
187
187
  operationId: getLabSettings
188
188
  summary: Read the lab's settings and AI provider
189
189
  description: |
190
- The lab-wide settings stored in the database (health checks and build cleanup), merged
191
- with the AI settings currently in effect. API keys are never returned: `geminiSet`,
192
- `openaiSet` and `anthropicSet` say only whether one is configured. A setting never saved
193
- is absent. Needs the `ADMIN` role or above, as the dashboard's settings pages do; through
194
- 2.12 any signed-in user could read it.
190
+ The lab-wide settings in effect (health checks, build cleanup and the AI self-healing
191
+ switch), merged with the AI settings currently in effect. Each setting is the value saved
192
+ in the dashboard, else the plugin option the server was started with, else the default,
193
+ which is also what the server runs with. `defaults` holds the defaults themselves, which
194
+ is what "restore defaults" goes back to. Only `healthCheckSchedule` can be absent: no
195
+ schedule is set. Through 2.13 `enableSelfHealing` was neither stored nor sent, so the
196
+ Settings page always showed it on.
197
+ API keys are never returned: `geminiSet`, `openaiSet` and `anthropicSet` say only
198
+ whether one is configured. Needs the `ADMIN` role or above, as the dashboard's settings
199
+ pages do; through 2.12 any signed-in user could read it. Through 2.13 a setting never
200
+ saved was absent, and `defaults` was not sent.
195
201
  tags:
196
202
  - Configuration
197
203
  responses:
@@ -202,8 +208,19 @@ paths:
202
208
  schema:
203
209
  $ref: '#/components/schemas/PlatformSettings'
204
210
  example:
211
+ enableSelfHealing: true
205
212
  healthCheckIntervalMs: 300000
206
213
  buildCleanupDays: 30
214
+ buildCleanupMaxCount: 100
215
+ buildCleanupSchedule: 0 0 * * *
216
+ deleteBuildAssets: true
217
+ defaults:
218
+ enableSelfHealing: true
219
+ healthCheckIntervalMs: 300000
220
+ buildCleanupDays: 30
221
+ buildCleanupMaxCount: 100
222
+ buildCleanupSchedule: 0 0 * * *
223
+ deleteBuildAssets: true
207
224
  aiProvider: gemini
208
225
  aiModel: gemini-3-flash-preview
209
226
  geminiModel: gemini-3-flash-preview
@@ -233,7 +250,22 @@ paths:
233
250
  Saves the lab-wide settings and changes the AI provider settings. Every field is
234
251
  optional; fields not sent are left as they are, and unknown fields are ignored.
235
252
 
236
- - Health-check and build-cleanup fields are stored in the database.
253
+ - Health-check and build-cleanup fields are stored in the database. A stored value
254
+ replaces the plugin option the server was started with, and applies without a
255
+ restart: the health check within a minute, the retention values at the next cleanup
256
+ run, and a new `buildCleanupSchedule` at once. The cleanup fields are checked first
257
+ and refused with `400` when they could not work: `buildCleanupDays` and
258
+ `buildCleanupMaxCount` must be whole numbers of at least 1, `buildCleanupSchedule` a
259
+ cron expression of five or six fields, `deleteBuildAssets` true or false. Nothing is
260
+ saved then. Send only the fields to change: a field sent is saved as the lab's own.
261
+ Through 2.13 only one setting could be saved: saving a second failed with a `500`.
262
+ - `enableSelfHealing` (true or false; anything else is refused with `400`) is the AI
263
+ self-healing switch. A stored value replaces the plugin option the server was started
264
+ with, and applies to the next command this server runs, with no restart. A session's
265
+ own `xe:options.healingTiers` still limits the tiers it may use. Through 2.13 the
266
+ switch was accepted and ignored. The value belongs to the server it is saved on:
267
+ saving it on a hub changes the hub's own sessions, not those on a node's phones,
268
+ which the node's own setting decides.
237
269
  - AI fields (`aiProvider`, `aiModel`, `aiBaseUrl` and the per-provider models) change the
238
270
  running server only. They are not stored, so a restart returns to the values the
239
271
  server was started with. API keys can't be set here; they come from the server's
@@ -254,6 +286,7 @@ paths:
254
286
  healthCheckIntervalMs: 300000
255
287
  buildCleanupDays: 14
256
288
  deleteBuildAssets: true
289
+ enableSelfHealing: false
257
290
  aiProvider: anthropic
258
291
  anthropicModel: claude-sonnet-4-6
259
292
  responses:
@@ -265,6 +298,16 @@ paths:
265
298
  $ref: '#/components/schemas/Success'
266
299
  example:
267
300
  success: true
301
+ '400':
302
+ description: A build-cleanup field or the self-healing switch has a value that could not work. Nothing was saved.
303
+ content:
304
+ application/json:
305
+ schema:
306
+ $ref: '#/components/schemas/Error'
307
+ example:
308
+ error: invalid_setting
309
+ field: buildCleanupDays
310
+ message: buildCleanupDays must be a whole number of at least 1.
268
311
  '401':
269
312
  $ref: '#/components/responses/Unauthorized'
270
313
  '403':
@@ -279,7 +322,7 @@ paths:
279
322
  $ref: '#/components/schemas/Error'
280
323
  example:
281
324
  error: true
282
- message: Unique constraint failed on the fields (`id`)
325
+ message: Can't reach database server
283
326
  /api/config/reset-metrics:
284
327
  post:
285
328
  operationId: resetDeviceHealCounts
@@ -415,21 +458,39 @@ paths:
415
458
  description: |
416
459
  Adds a webhook, active at once. When one of its `events` happens Xenon POSTs to `url`:
417
460
 
418
- - with `payloadTemplate`: the template, with each `{{key}}` (or `{{a.b}}`) replaced from
419
- the event (`eventType` plus the event's fields), sent as JSON if the result parses as
420
- JSON, otherwise as `{ "text": "<result>" }`;
461
+ - with `payloadTemplate`: the template, with each `{{name}}` (or `{{a.b}}`, `{{list.0.b}}`)
462
+ replaced from the event (`eventType` plus the event's fields, below). A template that
463
+ is JSON as written is filled in string by string, so a value holding a quote or a line
464
+ break can't break it, and is sent as that JSON. Any other template is filled in as
465
+ text and sent as JSON if the result parses, otherwise as `{ "text": "<result>" }`. A
466
+ `{{name}}` the event doesn't have is left as written;
421
467
  - otherwise, `type: slack`: a Slack message with an attachment;
422
468
  - otherwise: `{ "event": "<event>", "payload": { ... } }`.
423
469
 
424
- Each delivery is one POST, never retried. A template whose result isn't JSON is sent
425
- once, as text. A webhook that refuses an event or can't be reached doesn't fail the
426
- event: the failure is logged on the server and the other webhooks still get it. Use
427
- `POST /api/webhook/test` to see whether a webhook accepts deliveries.
470
+ Each delivery is one POST, never retried. A webhook that refuses an event or can't be
471
+ reached doesn't fail the event: the failure is logged on the server and the other
472
+ webhooks still get it. Use `POST /api/webhook/test` to see whether a webhook accepts
473
+ deliveries.
428
474
 
429
- Events are `device_offline`, `session_failed`, `device_new` and
430
- `selector_health_digest`. Neither `url` nor the event names are checked. Needs the
431
- `ADMIN` role and the `admin` scope. With the dashboard cookie the request must come from
432
- the same host.
475
+ What each event carries (a device event also has the device's other fields, which may
476
+ change; only the names listed are promised):
477
+
478
+ - `device_offline`, `device_new`: `udid`, `name`, `host`, `platform`.
479
+ Sent when a device stops being reported, or is first detected.
480
+
481
+ - `session_failed`: `sessionId`, `sessionName`, `failureReason`, `udid`, `deviceName`,
482
+ `platform`, `osVersion`, `startTime`, `endTime` (ISO 8601; unknown values are empty
483
+ text). Sent once per session when it ends as failed: the test marked it failed, a command
484
+ failed, it timed out for inactivity, its driver crashed, or its heartbeat stopped. Not
485
+ when the server itself shuts down, not for a session already failed when the server
486
+ started, and not when the dashboard is off, since the session's record is what it is
487
+ built from.
488
+
489
+ - `selector_health_digest`: `windowDays`, `totalHeals`, `distinctSelectors` and
490
+ `hotspots`, a list of `healCount`, `originalSelector` and `suggestedRewrite`.
491
+
492
+ Neither `url` nor the event names are checked. Needs the `ADMIN` role and the `admin`
493
+ scope. With the dashboard cookie the request must come from the same host.
433
494
  tags:
434
495
  - Webhooks
435
496
  requestBody:
@@ -536,11 +597,12 @@ paths:
536
597
  operationId: testWebhook
537
598
  summary: Send a test message to a URL
538
599
  description: |
539
- Sends a sample `device_new` event (a device named `Test Device`, udid
540
- `test-device-udid`, host `127.0.0.1`) to `url` the way a webhook with this `type` and
541
- `payloadTemplate` gets its real events (see `POST /api/webhook`): the filled-in
542
- template if there is one, else a Slack message for `type: slack` (the default), else
543
- `{ "event": "device_new", "payload": { ... } }`. No stored webhook is involved.
600
+ Sends a sample of `event` (`device_new` unless named; a device named `Test Device`, udid
601
+ `test-device-udid`, host `127.0.0.1`, or for `session_failed` a failed session on it) to
602
+ `url` the way a webhook with this `type` and `payloadTemplate` gets its real events (see
603
+ `POST /api/webhook`, which lists what each event carries): the filled-in template if
604
+ there is one, else a Slack message for `type: slack` (the default), else
605
+ `{ "event": "<event>", "payload": { ... } }`. No stored webhook is involved.
544
606
 
545
607
  Answers `200` when `url` accepted the delivery (a 2xx), and `502 delivery_failed`, with
546
608
  the reason, when it refused it or couldn't be reached. Needs the `ADMIN` role and the
@@ -575,6 +637,16 @@ paths:
575
637
  The webhook's template, filled in from the sample event as a real event's
576
638
  would be.
577
639
  example: '{"text": "{{eventType}}: {{name}} ({{udid}})"}'
640
+ event:
641
+ type: string
642
+ default: device_new
643
+ enum:
644
+ - device_offline
645
+ - session_failed
646
+ - device_new
647
+ - selector_health_digest
648
+ description: Which event to send a sample of.
649
+ example: session_failed
578
650
  example:
579
651
  url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
580
652
  type: slack
@@ -588,13 +660,18 @@ paths:
588
660
  example:
589
661
  success: true
590
662
  '400':
591
- description: '`url` is missing or not a string.'
663
+ description: '`url` is missing or not a string, or `event` is not one of the four.'
592
664
  content:
593
665
  application/json:
594
666
  schema:
595
667
  $ref: '#/components/schemas/Error'
596
- example:
597
- error: url is required
668
+ examples:
669
+ noUrl:
670
+ value:
671
+ error: url is required
672
+ badEvent:
673
+ value:
674
+ error: 'event must be one of: device_offline, session_failed, device_new, selector_health_digest'
598
675
  '401':
599
676
  $ref: '#/components/responses/Unauthorized'
600
677
  '403':
@@ -2264,8 +2341,12 @@ components:
2264
2341
  example: 734120
2265
2342
  PlatformSettings:
2266
2343
  type: object
2267
- description: The lab's stored settings, merged with the AI settings in effect. A setting never saved is absent.
2344
+ description: The lab's settings in effect (saved, else the startup option, else the default), merged with the AI settings in effect. Only `healthCheckSchedule` can be absent.
2268
2345
  properties:
2346
+ enableSelfHealing:
2347
+ type: boolean
2348
+ description: Whether a failed findElement goes on to the self-healing tiers.
2349
+ example: true
2269
2350
  healthCheckIntervalMs:
2270
2351
  type: integer
2271
2352
  example: 300000
@@ -2283,6 +2364,27 @@ components:
2283
2364
  example: 0 3 * * *
2284
2365
  deleteBuildAssets:
2285
2366
  type: boolean
2367
+ defaults:
2368
+ type: object
2369
+ description: The server's own defaults for the settings above, which is what "restore defaults" goes back to.
2370
+ properties:
2371
+ enableSelfHealing:
2372
+ type: boolean
2373
+ example: true
2374
+ healthCheckIntervalMs:
2375
+ type: integer
2376
+ example: 300000
2377
+ buildCleanupDays:
2378
+ type: integer
2379
+ example: 30
2380
+ buildCleanupMaxCount:
2381
+ type: integer
2382
+ example: 100
2383
+ buildCleanupSchedule:
2384
+ type: string
2385
+ example: 0 0 * * *
2386
+ deleteBuildAssets:
2387
+ type: boolean
2286
2388
  aiProvider:
2287
2389
  type: string
2288
2390
  example: gemini
@@ -2311,6 +2413,10 @@ components:
2311
2413
  type: object
2312
2414
  description: Any subset. Unknown fields are ignored.
2313
2415
  properties:
2416
+ enableSelfHealing:
2417
+ type: boolean
2418
+ description: The AI self-healing switch. Takes effect at the next command, with no restart.
2419
+ example: false
2314
2420
  healthCheckIntervalMs:
2315
2421
  type: integer
2316
2422
  example: 300000
@@ -2448,7 +2554,9 @@ components:
2448
2554
  example: slack
2449
2555
  payloadTemplate:
2450
2556
  type: string
2451
- description: A body template with `{{key}}` placeholders, used in place of the default body.
2557
+ description: >-
2558
+ A body template with `{{name}}` placeholders, used in place of the default body (see
2559
+ `POST /api/webhook` for the names each event has).
2452
2560
  example: '{"text":"{{eventType}} on {{udid}}"}'
2453
2561
  PlatformApp:
2454
2562
  type: object
@@ -625,7 +625,7 @@ const MJPEG_PROXY_CACHE = new Map();
625
625
  * Start stream endpoint - Initiates WDA and MJPEG streaming for iOS devices
626
626
  */
627
627
  router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
628
- var _a, _b, _c, _d, _e;
628
+ var _a, _b, _c, _d, _e, _f;
629
629
  const { udid } = req.params;
630
630
  // Someone is about to watch: a page that just left (a reload, or another tab
631
631
  // closing) must not stop the stream this page is starting.
@@ -709,7 +709,11 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
709
709
  // A recording device keeps MJPEG preview so we never run screenrecord and
710
710
  // the recording's screencap pipeline against the same device at once.
711
711
  const recording = yield typedi_1.Container.get(recording_store_1.RecordingStore).isRecording(udid);
712
- const streamType = (0, streamType_1.resolveStreamType)(device.platform, flagOn, recording);
712
+ // A page that shows MJPEG says so (`player: 'mjpeg'`): device control, a
713
+ // browser with no WebCodecs, or a page whose H.264 player just failed.
714
+ // Anything else is the server's choice.
715
+ const clientShowsMjpeg = ((_f = req.body) === null || _f === void 0 ? void 0 : _f.player) === 'mjpeg';
716
+ const streamType = (0, streamType_1.resolveStreamType)(device.platform, flagOn, recording, !clientShowsMjpeg);
713
717
  let mjpegPort;
714
718
  if (device.platform === 'ios' || device.platform === 'tvos') {
715
719
  mjpegPort = (yield typedi_1.Container.get(IOSStreamService_1.default).startStream(udid)).mjpegPort;
@@ -720,6 +724,12 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
720
724
  yield typedi_1.Container.get(AndroidH264StreamService_1.default).start(udid, { source: h264Cfg.source });
721
725
  }
722
726
  else {
727
+ // One capture pipeline per device: an H.264 capture still running for it
728
+ // (a tile's, or this page's own before its player failed) ends before the
729
+ // screencap loop starts.
730
+ const h264 = typedi_1.Container.get(AndroidH264StreamService_1.default);
731
+ if (h264.getMultiplexer(udid))
732
+ yield h264.stop(udid);
723
733
  mjpegPort = (yield typedi_1.Container.get(AndroidStreamService_1.default).startStream(udid)).mjpegPort;
724
734
  }
725
735
  // Lazy-load the screen size if missing: tiles need it to accept taps.
@@ -56,6 +56,9 @@ const prisma_1 = require("../../prisma");
56
56
  const SessionManager_1 = require("../../sessions/SessionManager");
57
57
  const UniversalMjpegProxy_1 = require("../../helpers/UniversalMjpegProxy");
58
58
  const web_config_service_1 = require("../../data-service/web-config-service");
59
+ const PluginContext_1 = require("../../PluginContext");
60
+ const labSettings_1 = require("../../services/settings/labSettings");
61
+ const SelfHealingSwitch_1 = require("../../services/settings/SelfHealingSwitch");
59
62
  const typedi_1 = require("typedi");
60
63
  const scopeGuard_1 = require("../../middleware/scopeGuard");
61
64
  const roleGuard_1 = require("../../middleware/roleGuard");
@@ -1103,6 +1106,10 @@ function getGlobalConfig(request, response) {
1103
1106
  return __awaiter(this, void 0, void 0, function* () {
1104
1107
  try {
1105
1108
  const dbConfig = yield typedi_1.Container.get(web_config_service_1.WebConfigService).getConfig();
1109
+ // What the server runs with: a saved value, else the startup option, else
1110
+ // the default. The pages show this (a saved value alone left them to guess
1111
+ // at what a setting never saved was), and `defaults` for "restore defaults".
1112
+ const effective = (0, labSettings_1.effectiveSettings)(typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs, dbConfig);
1106
1113
  // Merge with Environment Config (AI Settings)
1107
1114
  const { config } = yield Promise.resolve().then(() => __importStar(require('../../config')));
1108
1115
  // Sanitize keys - return boolean existence only
@@ -1118,7 +1125,9 @@ function getGlobalConfig(request, response) {
1118
1125
  openaiSet: !!config.openaiApiKey,
1119
1126
  anthropicSet: !!config.anthropicApiKey,
1120
1127
  };
1121
- return response.status(200).json(Object.assign(Object.assign({}, dbConfig), aiConfig));
1128
+ return response
1129
+ .status(200)
1130
+ .json(Object.assign(Object.assign(Object.assign(Object.assign({}, dbConfig), effective), { defaults: (0, labSettings_1.settingsDefaults)() }), aiConfig));
1122
1131
  }
1123
1132
  catch (err) {
1124
1133
  return response.status(500).json({ error: true, message: err.message });
@@ -1129,6 +1138,14 @@ function updateGlobalConfig(request, response) {
1129
1138
  return __awaiter(this, void 0, void 0, function* () {
1130
1139
  try {
1131
1140
  const payload = request.body;
1141
+ // Before anything is saved: a value the cleanup job would act on, such as a
1142
+ // retention window of 0, is refused rather than stored.
1143
+ const problem = (0, labSettings_1.validateSettingsUpdate)(payload !== null && payload !== void 0 ? payload : {});
1144
+ if (problem) {
1145
+ return response
1146
+ .status(400)
1147
+ .json({ error: 'invalid_setting', field: problem.field, message: problem.message });
1148
+ }
1132
1149
  // Handle Runtime AI Config Overrides (Memory only)
1133
1150
  // Only pass defined values to avoid overwriting env vars (e.g. aiBaseUrl, ollamaModel) with undefined
1134
1151
  const runtimeOverrides = {};
@@ -1152,6 +1169,17 @@ function updateGlobalConfig(request, response) {
1152
1169
  }
1153
1170
  // Persist Web Configs to DB
1154
1171
  yield typedi_1.Container.get(web_config_service_1.WebConfigService).setConfig(payload);
1172
+ // The command interceptor asks at every command and never reads the
1173
+ // database: hand it the saved value, in force from the next command.
1174
+ if (payload.enableSelfHealing !== undefined) {
1175
+ typedi_1.Container.get(SelfHealingSwitch_1.SelfHealingSwitch).set(payload.enableSelfHealing);
1176
+ }
1177
+ // The retention values are read at each cleanup run, so they apply by
1178
+ // themselves. A schedule is a timer: replace it now, not at the next restart.
1179
+ if (payload.buildCleanupSchedule !== undefined) {
1180
+ const { rescheduleCleanupBuilds } = yield Promise.resolve().then(() => __importStar(require('../../device-utils')));
1181
+ yield rescheduleCleanupBuilds(typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs);
1182
+ }
1155
1183
  return response.status(200).json({ success: true });
1156
1184
  }
1157
1185
  catch (err) {
@@ -402,10 +402,33 @@ function updateTags(request, response) {
402
402
  if (!udid || !host || !Array.isArray(tags)) {
403
403
  return response.status(400).json({ error: 'Missing udid, host, or tags array' });
404
404
  }
405
+ // The tags are saved for the phone (deviceSettings.ts): only for one that
406
+ // is here. Through 2.13 any udid and host answered 200 and wrote nothing.
407
+ if (!(yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid, host }))) {
408
+ return response.status(404).json({ error: 'Device not found' });
409
+ }
405
410
  yield (0, device_service_1.updateDeviceTags)(udid, host, tags);
406
411
  response.status(200).json({ success: true });
407
412
  });
408
413
  }
414
+ /**
415
+ * Every phone of this udid that this server knows: those connected now, and
416
+ * those whose settings are saved while they are away (deviceSettings.ts), so
417
+ * a phone that is not connected can still be moved, and its team deleted.
418
+ */
419
+ function phonesOfUdid(udid) {
420
+ return __awaiter(this, void 0, void 0, function* () {
421
+ const store = device_store_1.DeviceStoreFactory.getStore();
422
+ const phones = new Map();
423
+ for (const phone of [
424
+ ...(yield store.findDevices({ udid })),
425
+ ...(yield store.findSavedPhones(udid)),
426
+ ]) {
427
+ phones.set(`${phone.udid}\u0000${phone.host}`, { udid: phone.udid, host: phone.host });
428
+ }
429
+ return [...phones.values()];
430
+ });
431
+ }
409
432
  function assignDeviceToTeam(request, response) {
410
433
  return __awaiter(this, void 0, void 0, function* () {
411
434
  const { udid } = request.params;
@@ -415,20 +438,19 @@ function assignDeviceToTeam(request, response) {
415
438
  if (!team)
416
439
  return response.status(404).json({ error: 'team not found' });
417
440
  }
418
- const result = yield prisma_1.prisma.device.updateMany({
419
- where: { udid },
420
- data: { teamId: teamId || null },
421
- });
422
- if (result.count === 0)
441
+ const phones = yield phonesOfUdid(udid);
442
+ if (phones.length === 0)
423
443
  return response.status(404).json({ error: 'device not found' });
424
- // Refresh in-memory store so subsequent allocation sees the change immediately.
425
- const rows = yield prisma_1.prisma.device.findMany({ where: { udid } });
426
- for (const row of rows) {
427
- yield store.updateDevice(row.udid, row.host, { teamId: row.teamId });
444
+ // Through the store, which saves the team for the phone as it writes the
445
+ // row: a phone keeps it when it disconnects and comes back.
446
+ for (const phone of phones) {
447
+ yield device_store_1.DeviceStoreFactory.getStore().updateDevice(phone.udid, phone.host, {
448
+ teamId: teamId || null,
449
+ });
428
450
  }
429
451
  // The live dashboard events follow the new team now, not after the cache's TTL.
430
452
  typedi_1.Container.get(DeviceTeamResolver_1.DeviceTeamResolver).note(udid, teamId || null);
431
- response.json({ ok: true, updated: result.count });
453
+ response.json({ ok: true, updated: phones.length });
432
454
  });
433
455
  }
434
456
  /**