@tapflowio/relay 0.27.0 → 0.28.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 (266) hide show
  1. package/dist/RelayServer.d.ts +74 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +482 -43
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +63 -2
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +123 -1
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/api/apps.d.ts +2 -1
  10. package/dist/api/apps.d.ts.map +1 -1
  11. package/dist/api/apps.js +20 -7
  12. package/dist/api/apps.js.map +1 -1
  13. package/dist/api/builds.d.ts +2 -1
  14. package/dist/api/builds.d.ts.map +1 -1
  15. package/dist/api/builds.js +23 -45
  16. package/dist/api/builds.js.map +1 -1
  17. package/dist/api/comments.d.ts.map +1 -1
  18. package/dist/api/comments.js +5 -7
  19. package/dist/api/comments.js.map +1 -1
  20. package/dist/api/invitations.d.ts.map +1 -1
  21. package/dist/api/invitations.js +3 -0
  22. package/dist/api/invitations.js.map +1 -1
  23. package/dist/api/profile.d.ts.map +1 -1
  24. package/dist/api/profile.js +28 -9
  25. package/dist/api/profile.js.map +1 -1
  26. package/dist/api/recordings.d.ts.map +1 -1
  27. package/dist/api/recordings.js +5 -6
  28. package/dist/api/recordings.js.map +1 -1
  29. package/dist/api/runVideos.d.ts +85 -0
  30. package/dist/api/runVideos.d.ts.map +1 -0
  31. package/dist/api/runVideos.js +494 -0
  32. package/dist/api/runVideos.js.map +1 -0
  33. package/dist/api/runs.d.ts +77 -0
  34. package/dist/api/runs.d.ts.map +1 -0
  35. package/dist/api/runs.js +579 -0
  36. package/dist/api/runs.js.map +1 -0
  37. package/dist/api/settings.d.ts +1 -1
  38. package/dist/api/settings.d.ts.map +1 -1
  39. package/dist/api/settings.js +67 -10
  40. package/dist/api/settings.js.map +1 -1
  41. package/dist/db.d.ts +2 -0
  42. package/dist/db.d.ts.map +1 -1
  43. package/dist/db.js +4 -0
  44. package/dist/db.js.map +1 -1
  45. package/dist/lib/buildDeletion.d.ts +21 -0
  46. package/dist/lib/buildDeletion.d.ts.map +1 -0
  47. package/dist/lib/buildDeletion.js +58 -0
  48. package/dist/lib/buildDeletion.js.map +1 -0
  49. package/dist/lib/clientAddress.d.ts +12 -0
  50. package/dist/lib/clientAddress.d.ts.map +1 -1
  51. package/dist/lib/clientAddress.js +22 -0
  52. package/dist/lib/clientAddress.js.map +1 -1
  53. package/dist/lib/uploads.d.ts +13 -0
  54. package/dist/lib/uploads.d.ts.map +1 -1
  55. package/dist/lib/uploads.js +41 -0
  56. package/dist/lib/uploads.js.map +1 -1
  57. package/dist/lib/watchActions.d.ts +10 -0
  58. package/dist/lib/watchActions.d.ts.map +1 -0
  59. package/dist/lib/watchActions.js +27 -0
  60. package/dist/lib/watchActions.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +1 -0
  62. package/dist/middleware/auth.d.ts.map +1 -1
  63. package/dist/middleware/auth.js +1 -1
  64. package/dist/middleware/auth.js.map +1 -1
  65. package/dist/migrations/015_flow_runs.sql +59 -0
  66. package/dist/migrations/016_flow_run_videos.sql +51 -0
  67. package/dist/router.d.ts +5 -0
  68. package/dist/router.d.ts.map +1 -1
  69. package/dist/router.js +24 -0
  70. package/dist/router.js.map +1 -1
  71. package/package.json +9 -9
  72. package/public/assets/AISessionWatch-Bxew762e.js +36 -0
  73. package/public/assets/AISessionWatch-Bxew762e.js.br +0 -0
  74. package/public/assets/AISessionWatch-Bxew762e.js.gz +0 -0
  75. package/public/assets/AndroidDeviceScreen-C55msKfh.js +35 -0
  76. package/public/assets/AndroidDeviceScreen-C55msKfh.js.br +0 -0
  77. package/public/assets/AndroidDeviceScreen-C55msKfh.js.gz +0 -0
  78. package/public/assets/AppCenter-93hnrrdy.js +26 -0
  79. package/public/assets/AppCenter-93hnrrdy.js.br +0 -0
  80. package/public/assets/AppCenter-93hnrrdy.js.gz +0 -0
  81. package/public/assets/Default-BhgcUKBm.js +6 -0
  82. package/public/assets/Default-BhgcUKBm.js.br +0 -0
  83. package/public/assets/Default-BhgcUKBm.js.gz +0 -0
  84. package/public/assets/Invite-DQBwLaOl.js +1 -0
  85. package/public/assets/Invite-DQBwLaOl.js.br +0 -0
  86. package/public/assets/Invite-DQBwLaOl.js.gz +0 -0
  87. package/public/assets/MacResources-D4LMGxgV.js +1 -0
  88. package/public/assets/MacResources-D4LMGxgV.js.br +0 -0
  89. package/public/assets/MacResources-D4LMGxgV.js.gz +0 -0
  90. package/public/assets/{NotFound-wHOnT2Fl.js → NotFound-DVq_rCKc.js} +1 -1
  91. package/public/assets/NotFound-DVq_rCKc.js.br +0 -0
  92. package/public/assets/NotFound-DVq_rCKc.js.gz +0 -0
  93. package/public/assets/QASession-Dnuq0yrN.js +81 -0
  94. package/public/assets/QASession-Dnuq0yrN.js.br +0 -0
  95. package/public/assets/QASession-Dnuq0yrN.js.gz +0 -0
  96. package/public/assets/ResetPassword-inTbbral.js +1 -0
  97. package/public/assets/ResetPassword-inTbbral.js.br +0 -0
  98. package/public/assets/ResetPassword-inTbbral.js.gz +0 -0
  99. package/public/assets/RunDetail-B3dw8_U0.js +16 -0
  100. package/public/assets/RunDetail-B3dw8_U0.js.br +0 -0
  101. package/public/assets/RunDetail-B3dw8_U0.js.gz +0 -0
  102. package/public/assets/Runs-DqZl9Y6M.js +6 -0
  103. package/public/assets/Runs-DqZl9Y6M.js.br +0 -0
  104. package/public/assets/Runs-DqZl9Y6M.js.gz +0 -0
  105. package/public/assets/Setup-CPa_UWpa.js +1 -0
  106. package/public/assets/Setup-CPa_UWpa.js.br +0 -0
  107. package/public/assets/Setup-CPa_UWpa.js.gz +0 -0
  108. package/public/assets/Team-DZie8W7R.js +6 -0
  109. package/public/assets/Team-DZie8W7R.js.br +0 -0
  110. package/public/assets/Team-DZie8W7R.js.gz +0 -0
  111. package/public/assets/Tokens-u3-uRAwd.js +1 -0
  112. package/public/assets/Tokens-u3-uRAwd.js.br +0 -0
  113. package/public/assets/Tokens-u3-uRAwd.js.gz +0 -0
  114. package/public/assets/WatchedDevice-CpaKHa1x.js +1 -0
  115. package/public/assets/WatchedDevice-CpaKHa1x.js.br +0 -0
  116. package/public/assets/WatchedDevice-CpaKHa1x.js.gz +0 -0
  117. package/public/assets/aiSession-BsmDnyV1.js +31 -0
  118. package/public/assets/aiSession-BsmDnyV1.js.br +0 -0
  119. package/public/assets/aiSession-BsmDnyV1.js.gz +0 -0
  120. package/public/assets/alert-dialog-Bq_Ln5bp.js +1 -0
  121. package/public/assets/alert-dialog-Bq_Ln5bp.js.br +0 -0
  122. package/public/assets/alert-dialog-Bq_Ln5bp.js.gz +0 -0
  123. package/public/assets/{badge-B3vaBVIQ.js → badge-D-bt97WR.js} +1 -1
  124. package/public/assets/badge-D-bt97WR.js.br +0 -0
  125. package/public/assets/badge-D-bt97WR.js.gz +0 -0
  126. package/public/assets/build-format-ofd2iIzf.js +1 -0
  127. package/public/assets/build-format-ofd2iIzf.js.br +0 -0
  128. package/public/assets/build-format-ofd2iIzf.js.gz +0 -0
  129. package/public/assets/dialog-CtzPkg0O.js +1 -0
  130. package/public/assets/dialog-CtzPkg0O.js.br +0 -0
  131. package/public/assets/dialog-CtzPkg0O.js.gz +0 -0
  132. package/public/assets/field-hint-B774BIWK.js +1 -0
  133. package/public/assets/field-hint-B774BIWK.js.br +1 -0
  134. package/public/assets/field-hint-B774BIWK.js.gz +0 -0
  135. package/public/assets/index-D17hSi50.js +1 -0
  136. package/public/assets/index-D17hSi50.js.br +0 -0
  137. package/public/assets/index-D17hSi50.js.gz +0 -0
  138. package/public/assets/index-EPyP3g9A.css +1 -0
  139. package/public/assets/index-EPyP3g9A.css.br +0 -0
  140. package/public/assets/index-EPyP3g9A.css.gz +0 -0
  141. package/public/assets/index-cgl1yEKu.js +223 -0
  142. package/public/assets/index-cgl1yEKu.js.br +0 -0
  143. package/public/assets/index-cgl1yEKu.js.gz +0 -0
  144. package/public/assets/list-view-BcjDCJy5.js +1 -0
  145. package/public/assets/list-view-BcjDCJy5.js.br +0 -0
  146. package/public/assets/list-view-BcjDCJy5.js.gz +0 -0
  147. package/public/assets/{pencil-CycAOoC_.js → pencil-1XSs32nb.js} +2 -2
  148. package/public/assets/pencil-1XSs32nb.js.br +0 -0
  149. package/public/assets/pencil-1XSs32nb.js.gz +0 -0
  150. package/public/assets/play-DCvrorIC.js +6 -0
  151. package/public/assets/play-DCvrorIC.js.br +0 -0
  152. package/public/assets/play-DCvrorIC.js.gz +0 -0
  153. package/public/assets/resource-health-oWJ8hy9X.js +1 -0
  154. package/public/assets/resource-health-oWJ8hy9X.js.br +0 -0
  155. package/public/assets/resource-health-oWJ8hy9X.js.gz +0 -0
  156. package/public/assets/runs-CInQb6v1.js +1 -0
  157. package/public/assets/runs-CInQb6v1.js.br +0 -0
  158. package/public/assets/runs-CInQb6v1.js.gz +0 -0
  159. package/public/assets/search-input-CFWgLcKA.js +6 -0
  160. package/public/assets/search-input-CFWgLcKA.js.br +0 -0
  161. package/public/assets/search-input-CFWgLcKA.js.gz +0 -0
  162. package/public/assets/select-5ZtzWRYG.js +11 -0
  163. package/public/assets/select-5ZtzWRYG.js.br +0 -0
  164. package/public/assets/select-5ZtzWRYG.js.gz +0 -0
  165. package/public/assets/slider-DHuMDXcP.js +1 -0
  166. package/public/assets/slider-DHuMDXcP.js.br +0 -0
  167. package/public/assets/slider-DHuMDXcP.js.gz +0 -0
  168. package/public/assets/tabs-CmgV_sSI.js +1 -0
  169. package/public/assets/tabs-CmgV_sSI.js.br +0 -0
  170. package/public/assets/tabs-CmgV_sSI.js.gz +0 -0
  171. package/public/assets/tech-label-qLodPeUI.js +1 -0
  172. package/public/assets/tech-label-qLodPeUI.js.br +0 -0
  173. package/public/assets/tech-label-qLodPeUI.js.gz +0 -0
  174. package/public/assets/{trash-BXQnOI5U.js → trash-CXjB9oU1.js} +3 -3
  175. package/public/assets/trash-CXjB9oU1.js.br +0 -0
  176. package/public/assets/trash-CXjB9oU1.js.gz +0 -0
  177. package/public/assets/{useFocusAfterSwap-BY3N9f9v.js → useFocusAfterSwap-BdItkQCu.js} +1 -1
  178. package/public/assets/useFocusAfterSwap-BdItkQCu.js.br +0 -0
  179. package/public/assets/useFocusAfterSwap-BdItkQCu.js.gz +0 -0
  180. package/public/assets/useRelay-CES3I1Mt.js +1 -0
  181. package/public/assets/useRelay-CES3I1Mt.js.br +0 -0
  182. package/public/assets/useRelay-CES3I1Mt.js.gz +0 -0
  183. package/public/assets/useShownAfter-By1Yxysz.js +1 -0
  184. package/public/assets/useShownAfter-By1Yxysz.js.br +0 -0
  185. package/public/assets/useShownAfter-By1Yxysz.js.gz +0 -0
  186. package/public/assets/{vendor-charts-BykzUV2D.js → vendor-charts-BE7R_8yA.js} +2 -2
  187. package/public/assets/vendor-charts-BE7R_8yA.js.br +0 -0
  188. package/public/assets/vendor-charts-BE7R_8yA.js.gz +0 -0
  189. package/public/assets/{vendor-react-BlPSzrEe.js → vendor-react-DTSdA8m-.js} +4 -4
  190. package/public/assets/vendor-react-DTSdA8m-.js.br +0 -0
  191. package/public/assets/{vendor-react-BlPSzrEe.js.gz → vendor-react-DTSdA8m-.js.gz} +0 -0
  192. package/public/assets/{vendor-router-B_lVHrKl.js → vendor-router-B1pQim9Z.js} +3 -3
  193. package/public/assets/vendor-router-B1pQim9Z.js.br +0 -0
  194. package/public/assets/vendor-router-B1pQim9Z.js.gz +0 -0
  195. package/public/assets/video-CK2q3-0B.js +6 -0
  196. package/public/assets/video-CK2q3-0B.js.br +0 -0
  197. package/public/assets/video-CK2q3-0B.js.gz +0 -0
  198. package/public/index.html +18 -4
  199. package/public/index.html.br +0 -0
  200. package/public/index.html.gz +0 -0
  201. package/public/assets/AppCenter-BMdYtQbV.js +0 -26
  202. package/public/assets/AppCenter-BMdYtQbV.js.br +0 -0
  203. package/public/assets/AppCenter-BMdYtQbV.js.gz +0 -0
  204. package/public/assets/Default-0DHHVGaN.js +0 -1
  205. package/public/assets/Default-0DHHVGaN.js.br +0 -0
  206. package/public/assets/Default-0DHHVGaN.js.gz +0 -0
  207. package/public/assets/Invite-C0ZB_HiU.js +0 -1
  208. package/public/assets/Invite-C0ZB_HiU.js.br +0 -0
  209. package/public/assets/Invite-C0ZB_HiU.js.gz +0 -0
  210. package/public/assets/MacResources-DmY5pRV2.js +0 -1
  211. package/public/assets/MacResources-DmY5pRV2.js.br +0 -0
  212. package/public/assets/MacResources-DmY5pRV2.js.gz +0 -0
  213. package/public/assets/NotFound-wHOnT2Fl.js.br +0 -0
  214. package/public/assets/NotFound-wHOnT2Fl.js.gz +0 -0
  215. package/public/assets/QASession-2QogWEX7.js +0 -150
  216. package/public/assets/QASession-2QogWEX7.js.br +0 -0
  217. package/public/assets/QASession-2QogWEX7.js.gz +0 -0
  218. package/public/assets/ResetPassword-jNRuertx.js +0 -1
  219. package/public/assets/ResetPassword-jNRuertx.js.br +0 -0
  220. package/public/assets/ResetPassword-jNRuertx.js.gz +0 -0
  221. package/public/assets/Setup-CF_FN9Dt.js +0 -1
  222. package/public/assets/Setup-CF_FN9Dt.js.br +0 -0
  223. package/public/assets/Setup-CF_FN9Dt.js.gz +0 -0
  224. package/public/assets/Team-BbiyOGau.js +0 -6
  225. package/public/assets/Team-BbiyOGau.js.br +0 -0
  226. package/public/assets/Team-BbiyOGau.js.gz +0 -0
  227. package/public/assets/Tokens-CZo4sPTx.js +0 -1
  228. package/public/assets/Tokens-CZo4sPTx.js.br +0 -0
  229. package/public/assets/Tokens-CZo4sPTx.js.gz +0 -0
  230. package/public/assets/alert-dialog-DFz8G_zu.js +0 -1
  231. package/public/assets/alert-dialog-DFz8G_zu.js.br +0 -0
  232. package/public/assets/alert-dialog-DFz8G_zu.js.gz +0 -0
  233. package/public/assets/badge-B3vaBVIQ.js.br +0 -0
  234. package/public/assets/badge-B3vaBVIQ.js.gz +0 -0
  235. package/public/assets/build-format-dbQPU3A4.js +0 -11
  236. package/public/assets/build-format-dbQPU3A4.js.br +0 -0
  237. package/public/assets/build-format-dbQPU3A4.js.gz +0 -0
  238. package/public/assets/dialog-DeBPk-I3.js +0 -11
  239. package/public/assets/dialog-DeBPk-I3.js.br +0 -0
  240. package/public/assets/dialog-DeBPk-I3.js.gz +0 -0
  241. package/public/assets/field-hint-CMKmbVpy.js +0 -1
  242. package/public/assets/field-hint-CMKmbVpy.js.br +0 -0
  243. package/public/assets/field-hint-CMKmbVpy.js.gz +0 -0
  244. package/public/assets/index-B4w4Lh3e.js +0 -218
  245. package/public/assets/index-B4w4Lh3e.js.br +0 -0
  246. package/public/assets/index-B4w4Lh3e.js.gz +0 -0
  247. package/public/assets/index-CDWu0GQL.css +0 -1
  248. package/public/assets/index-CDWu0GQL.css.br +0 -0
  249. package/public/assets/index-CDWu0GQL.css.gz +0 -0
  250. package/public/assets/list-view-BvINA5t4.js +0 -1
  251. package/public/assets/list-view-BvINA5t4.js.br +0 -0
  252. package/public/assets/list-view-BvINA5t4.js.gz +0 -0
  253. package/public/assets/pencil-CycAOoC_.js.br +0 -0
  254. package/public/assets/pencil-CycAOoC_.js.gz +0 -0
  255. package/public/assets/resource-health-C1qSG3MK.js +0 -1
  256. package/public/assets/resource-health-C1qSG3MK.js.br +0 -0
  257. package/public/assets/resource-health-C1qSG3MK.js.gz +0 -0
  258. package/public/assets/trash-BXQnOI5U.js.br +0 -0
  259. package/public/assets/trash-BXQnOI5U.js.gz +0 -0
  260. package/public/assets/useFocusAfterSwap-BY3N9f9v.js.br +0 -0
  261. package/public/assets/useFocusAfterSwap-BY3N9f9v.js.gz +0 -0
  262. package/public/assets/vendor-charts-BykzUV2D.js.br +0 -0
  263. package/public/assets/vendor-charts-BykzUV2D.js.gz +0 -0
  264. package/public/assets/vendor-react-BlPSzrEe.js.br +0 -0
  265. package/public/assets/vendor-router-B_lVHrKl.js.br +0 -0
  266. package/public/assets/vendor-router-B_lVHrKl.js.gz +0 -0
@@ -5,12 +5,13 @@ import os from 'os';
5
5
  import path from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
- import { SessionManager } from './SessionManager.js';
8
+ import { SessionManager, isAiClientKind } from './SessionManager.js';
9
+ import { gestureAction, keyForWatchers } from './lib/watchActions.js';
9
10
  import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
10
11
  import { Router, json } from './router.js';
11
12
  import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
12
- import { AGENT_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
13
- import { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
13
+ import { AGENT_SCOPE, VIEW_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
14
+ import { isTunnelIngress, markTunnelIngress, proxiedWithoutForwardedFor, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
14
15
  import { BuildTicketStore } from './lib/buildTickets.js';
15
16
  import { resolveBuildFile } from './lib/buildFiles.js';
16
17
  import { resolveCorsHeaders } from './lib/cors.js';
@@ -23,6 +24,7 @@ import { getDb } from './db.js';
23
24
  import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
24
25
  import { handleVerify, handleAccept } from './api/invitations.js';
25
26
  import { createLogger, hasCapability } from '@tapflowio/agent-core';
27
+ import { acquireLoopStallWatch } from '@tapflowio/agent-core/utils';
26
28
  import { createKeyframeAwareSender, createRateLimitedDropWarn, sendAudioYieldingToVideo, DEFAULT_BACKPRESSURE_BYTES, hasEnvelope, patchRelayedAt, readEnvelopeFlags, CODEC_JPEG, CODEC_AUDIO, } from '@tapflowio/agent-core/utils';
27
29
  const logger = createLogger('relay');
28
30
  // True when a remote IP is public — i.e. not loopback, private LAN, or link-local. Used to pick the
@@ -88,6 +90,8 @@ import { handleListTokens, handleCreateToken, handleRevokeToken } from './api/to
88
90
  import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
89
91
  import { handleUpdateProfile } from './api/profile.js';
90
92
  import { handleUploadRecording, handleListRecordings, handleDownloadRecording, purgeExpiredRecordings } from './api/recordings.js';
93
+ import { handleEndVideo, handleOpenVideo, handleVideoChunk, handleVideoData, handleVideoIndex, purgeRunVideos, releaseStaleVideoReservations, runVideosDirFor, videoSummaries } from './api/runVideos.js';
94
+ import { abandonRunsOf, handleCreateRun, handleFinishRun, handleGetFlowScreenshot, handleGetRun, handleListRuns, handleReportFlow, handleUploadFlowScreenshot, purgeExpiredRuns, runScreenshotsDirFor } from './api/runs.js';
91
95
  import { handleListAgents, handleGetAgentResources } from './api/agents.js';
92
96
  const MIME_TYPES = {
93
97
  '.html': 'text/html',
@@ -209,11 +213,22 @@ export class RelayServer {
209
213
  resourceBuffers = new Map();
210
214
  logBuffer = [];
211
215
  recordingsDir = '';
216
+ runScreenshotsDir = '';
217
+ runVideosDir = '';
212
218
  purgeRecordingsTimer = null;
213
219
  purgeOldResourcesTimer = null;
214
220
  purgeBuildsTimer = null;
215
221
  flushResourcesTimer = null;
216
222
  heartbeatTimer = null;
223
+ /** When the last heartbeat sweep ran, by the same clock `isAlive` judges with. */
224
+ lastSweepAt = 0;
225
+ /** Names agents give at registration, kept apart from the session map so an agent with no sessions
226
+ * can still be named when its socket goes. */
227
+ agentNames = new WeakMap();
228
+ releaseStallWatch = null;
229
+ /** Agent sockets whose close was already explained — ended by a sweep, or replaced by the same agent
230
+ * registering again — so the close handler does not log them as a fresh drop. */
231
+ explainedCloses = new WeakMap();
217
232
  /**
218
233
  * When each socket last answered a ping. WeakMap → no manual cleanup on close (GC handles it).
219
234
  *
@@ -251,6 +266,19 @@ export class RelayServer {
251
266
  droppers = new Map();
252
267
  // Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
253
268
  idrRequesters = new Map();
269
+ /**
270
+ * **One keyframe-aware sender per watcher, never the session's.** The sender's `dropping` state lives in
271
+ * its closure, so sharing the holder's would let a slow watcher put the holder into drop-to-keyframe
272
+ * too — every P-frame to the holder discarded until the next IDR because someone else's link is slow.
273
+ * Keyed by socket, then session, because one socket can watch several sessions.
274
+ */
275
+ watcherDroppers = new Map();
276
+ /**
277
+ * Whether this connection may watch: a cookie, or a remote PAT with `view` — decided once at the
278
+ * handshake, from the credential. **Not** an unauthenticated loopback socket: a page in any browser on
279
+ * the relay's Mac reaches loopback without a credential, and watching shows a device's screen.
280
+ */
281
+ mayWatch = new WeakMap();
254
282
  /** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
255
283
  * `forgetSessionState`. */
256
284
  networkStateRequesters = new Map();
@@ -297,6 +325,7 @@ export class RelayServer {
297
325
  corsAllowed;
298
326
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
299
327
  warnedProxyMisconfig = false;
328
+ warnedTrustedProxyWithoutXff = false;
300
329
  /** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
301
330
  warnedLegacyInstaller = new Set();
302
331
  buildTickets = new BuildTicketStore();
@@ -367,7 +396,16 @@ export class RelayServer {
367
396
  if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
368
397
  logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
369
398
  }
370
- this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
399
+ this.sessions = new SessionManager({
400
+ idleTimeoutMs: options.idleTimeoutMs,
401
+ // Before the sockets are forgotten, so each one is told why its picture stopped.
402
+ onWatchersEvicted: (sessionId, sockets, reason) => {
403
+ for (const w of sockets) {
404
+ this.forgetWatcherDropper(w, sessionId);
405
+ this.sendTo(w, { type: 'watch:ended', sessionId, reason });
406
+ }
407
+ },
408
+ });
371
409
  this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
372
410
  this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
373
411
  this.router = new Router();
@@ -420,7 +458,7 @@ export class RelayServer {
420
458
  this.router.get('/api/v1/apps', handleListApps);
421
459
  this.router.post('/api/v1/apps', handleCreateApp);
422
460
  this.router.patch('/api/v1/apps/:id', handleUpdateApp);
423
- this.router.delete('/api/v1/apps/:id', handleDeleteApp);
461
+ this.router.delete('/api/v1/apps/:id', (req, res, params) => handleDeleteApp(req, res, params, this.buildFileDirs()));
424
462
  // builds
425
463
  this.router.get('/api/v1/builds', handleListBuilds);
426
464
  this.router.get('/api/v1/builds/:id', handleGetBuild);
@@ -454,7 +492,7 @@ export class RelayServer {
454
492
  this.router.post('/api/v1/tokens', handleCreateToken);
455
493
  this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
456
494
  // settings
457
- this.router.get('/api/v1/settings', handleGetSettings);
495
+ this.router.get('/api/v1/settings', (req, res) => handleGetSettings(req, res, this.runVideosDir));
458
496
  this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
459
497
  this.router.patch('/api/v1/profile', (req, res) => handleUpdateProfile(req, res, u));
460
498
  // recordings
@@ -462,6 +500,22 @@ export class RelayServer {
462
500
  this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
463
501
  this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
464
502
  this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, this.recordingsDir));
503
+ // flow run records — written by `tapflow flow run`, read by the runs page
504
+ this.runScreenshotsDir = runScreenshotsDirFor(u);
505
+ this.router.post('/api/v1/runs', handleCreateRun);
506
+ this.router.get('/api/v1/runs', (req, res) => handleListRuns(req, res, this.runHolders));
507
+ this.router.get('/api/v1/runs/:id', (req, res, params) => handleGetRun(req, res, params, this.runHolders, videoSummaries));
508
+ this.router.post('/api/v1/runs/:id/finish', handleFinishRun);
509
+ this.router.post('/api/v1/runs/:id/flows/:idx', handleReportFlow);
510
+ this.router.post('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleUploadFlowScreenshot(req, res, params, this.runScreenshotsDir));
511
+ this.router.get('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleGetFlowScreenshot(req, res, params, this.runScreenshotsDir));
512
+ // a flow's video (A4) — uploaded by the runner while the flow runs, kept by the retention policy
513
+ this.runVideosDir = runVideosDirFor(u);
514
+ this.router.post('/api/v1/runs/:id/flows/:idx/video/open', (req, res, params) => handleOpenVideo(req, res, params, this.runVideosDir));
515
+ this.router.post('/api/v1/runs/:id/flows/:idx/video/chunks/:seq', (req, res, params) => handleVideoChunk(req, res, params, this.runVideosDir, this.runHolders));
516
+ this.router.post('/api/v1/runs/:id/flows/:idx/video/end', handleEndVideo);
517
+ this.router.get('/api/v1/runs/:id/flows/:idx/video/index', (req, res, params) => handleVideoIndex(req, res, params, this.runHolders));
518
+ this.router.get('/api/v1/runs/:id/flows/:idx/video', (req, res, params) => handleVideoData(req, res, params, this.runHolders, this.runVideosDir));
465
519
  // logs — the relay host only. The buffer holds the addresses of refused connections, nothing
466
520
  // remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
467
521
  // output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
@@ -521,13 +575,31 @@ export class RelayServer {
521
575
  purgeOldResources();
522
576
  this.purgeOldResourcesTimer = setInterval(purgeOldResources, 24 * 60 * 60 * 1000);
523
577
  this.purgeOldResourcesTimer.unref();
524
- purgeExpiredBuilds(this.recordingsDir, this.uploadsDir);
525
- this.purgeBuildsTimer = setInterval(() => purgeExpiredBuilds(this.recordingsDir, this.uploadsDir), 24 * 60 * 60 * 1000);
578
+ // Runs ride the builds' daily tick rather than a timer of their own: a build's runs go with it, and the
579
+ // runs left over (no build) expire on the same clock.
580
+ const purgeBuildsAndRuns = () => {
581
+ purgeExpiredBuilds(this.buildFileDirs());
582
+ purgeExpiredRuns(this.runScreenshotsDir, this.runVideosDir, this.runHolders);
583
+ purgeRunVideos(this.runVideosDir, this.runHolders);
584
+ };
585
+ // Before the first purge or write: no write in flight survived the last process.
586
+ releaseStaleVideoReservations();
587
+ purgeBuildsAndRuns();
588
+ this.purgeBuildsTimer = setInterval(purgeBuildsAndRuns, 24 * 60 * 60 * 1000);
526
589
  this.purgeBuildsTimer.unref();
527
590
  this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
528
591
  this.flushResourcesTimer.unref();
529
- this.heartbeatTimer = setInterval(() => this.runHeartbeat(), HEARTBEAT_MS);
592
+ this.lastSweepAt = Date.now();
593
+ this.heartbeatTimer = setInterval(() => { this.noteLateSweep(); this.runHeartbeat(); }, HEARTBEAT_MS);
530
594
  this.heartbeatTimer.unref();
595
+ this.releaseStallWatch ??= acquireLoopStallWatch();
596
+ // The rule a proxy has to follow, said once to whoever set the list: the case it covers (a proxy that
597
+ // sends no forwarding header at all) is one no request-time check can see.
598
+ const trustedProxies = this.options.trustedProxies ?? [];
599
+ if (trustedProxies.length > 0) {
600
+ logger.info(`Trusted proxies: ${trustedProxies.join(', ')}. A request from one of these without ` +
601
+ 'X-Forwarded-For is treated as coming from that address itself, so the proxy must send X-Forwarded-For.');
602
+ }
531
603
  return new Promise((resolve, reject) => {
532
604
  this.httpServer.once('error', (err) => {
533
605
  if (err.code === 'EADDRINUSE') {
@@ -586,6 +658,8 @@ export class RelayServer {
586
658
  clearInterval(this.heartbeatTimer);
587
659
  this.heartbeatTimer = null;
588
660
  }
661
+ this.releaseStallWatch?.();
662
+ this.releaseStallWatch = null;
589
663
  // `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
590
664
  // still fires — against a server that has already stopped.
591
665
  this.stopping = true;
@@ -664,14 +738,42 @@ export class RelayServer {
664
738
  // interval, whatever path the change took. `onAuthChanged` is the same check run early.
665
739
  runHeartbeat(clients = this.wss.clients) {
666
740
  this.revalidateSockets(clients);
741
+ // Agents one line each; everything else summed, because a relay waking from sleep finds every socket
742
+ // stale at once and one line per browser tab would push the agent lines out of the log buffer.
743
+ const others = new Map();
667
744
  for (const ws of clients) {
668
745
  if (!this.isAlive(ws)) {
746
+ const role = this.wsRoles.get(ws);
747
+ if (role === 'agent') {
748
+ this.pushLog(`heartbeat terminated agent ${this.agentNames.get(ws) ?? 'unknown'} — last pong ${this.pongAgeSeconds(ws)}s ago`);
749
+ this.explainedCloses.set(ws, 'terminated');
750
+ }
751
+ else {
752
+ const key = role ?? 'unregistered';
753
+ others.set(key, (others.get(key) ?? 0) + 1);
754
+ }
669
755
  ws.terminate();
670
756
  continue;
671
757
  }
672
758
  if (ws.readyState === WebSocket.OPEN)
673
759
  ws.ping();
674
760
  }
761
+ if (others.size > 0) {
762
+ this.pushLog(`heartbeat terminated ${[...others].map(([role, n]) => `${n} ${role}`).join(', ')} socket(s)`);
763
+ }
764
+ }
765
+ /** A sweep that runs late means this process was held, or the machine slept: the pongs it judges were
766
+ * never read, so its terminations say nothing about the clients. Same clock as `isAlive`. */
767
+ noteLateSweep(now = Date.now()) {
768
+ const late = now - this.lastSweepAt - HEARTBEAT_MS;
769
+ if (late > HEARTBEAT_MS / 2) {
770
+ this.pushLog(`heartbeat sweep ran ${Math.round(late / 1000)}s late — the relay was stalled or the system slept; this sweep may end sockets that were alive`);
771
+ }
772
+ this.lastSweepAt = now;
773
+ }
774
+ pongAgeSeconds(ws) {
775
+ const at = this.lastPongAt.get(ws);
776
+ return at === undefined ? '?' : Math.round((Date.now() - at) / 1000);
675
777
  }
676
778
  /**
677
779
  * Injected into the handlers whose write can take access away — member update and removal, token
@@ -767,6 +869,32 @@ export class RelayServer {
767
869
  * negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
768
870
  * hands a live tester's session to whoever asked next.
769
871
  */
872
+ buildFileDirs() {
873
+ return { uploadsDir: this.uploadsDir, recordingsDir: this.recordingsDir, runScreenshotsDir: this.runScreenshotsDir, runVideosDir: this.runVideosDir };
874
+ }
875
+ /** Open sockets speaking for any of these owner keys. */
876
+ openSocketsOf(keys) {
877
+ return [...this.wss.clients].filter((ws) => ws.readyState === WebSocket.OPEN && keys.includes(this.ownerKey.get(ws)?.key ?? ''));
878
+ }
879
+ runHolders = {
880
+ connectedKeys: () => {
881
+ const keys = new Set();
882
+ for (const ws of this.wss.clients) {
883
+ const key = ws.readyState === WebSocket.OPEN ? this.ownerKey.get(ws)?.key : undefined;
884
+ if (key)
885
+ keys.add(key);
886
+ }
887
+ return keys;
888
+ },
889
+ heldSession: (keys) => {
890
+ for (const ws of this.openSocketsOf(keys)) {
891
+ const held = this.sessions.getByBrowserSocket(ws);
892
+ if (held.length > 0)
893
+ return held[0].id;
894
+ }
895
+ return null;
896
+ },
897
+ };
770
898
  isAlive(ws) {
771
899
  const at = this.lastPongAt.get(ws);
772
900
  // **No entry means just connected, not dead.** `handleConnection` seeds one, but a socket is in
@@ -809,6 +937,7 @@ export class RelayServer {
809
937
  */
810
938
  forgetSessionState(sessionId) {
811
939
  this.dropHandlers.delete(sessionId);
940
+ this.dropHandlers.delete(`${sessionId} watchers`);
812
941
  this.audioDropHandlers.delete(sessionId);
813
942
  this.droppers.delete(sessionId);
814
943
  this.idrRequesters.delete(sessionId);
@@ -897,6 +1026,24 @@ export class RelayServer {
897
1026
  remoteAddressOf(request) {
898
1027
  return request.socket.remoteAddress ?? '';
899
1028
  }
1029
+ /**
1030
+ * Called on both doors — the WebSocket upgrade and every HTTP request — since `auth/init` and the logs
1031
+ * route decide locality on the HTTP side. Not on the tunnel listener, where every request is remote
1032
+ * whatever it carries.
1033
+ */
1034
+ warnTrustedProxyWithoutXffOnce(request) {
1035
+ if (this.warnedTrustedProxyWithoutXff || isTunnelIngress(request))
1036
+ return;
1037
+ const header = proxiedWithoutForwardedFor(this.remoteAddressOf(request), request.headers, this.options.trustedProxies ?? []);
1038
+ if (!header)
1039
+ return;
1040
+ logger.warn(`A request from a trusted proxy (TAPFLOW_TRUSTED_PROXIES) carried ${header} but no X-Forwarded-For. ` +
1041
+ 'Without that header the relay cannot see the real client, so it treats the request as coming from the ' +
1042
+ 'proxy itself: local, and so unauthenticated, when that address is loopback. Configure the proxy to ' +
1043
+ 'send X-Forwarded-For. A proxy on this host can instead use the tunnel port, which is open only when a ' +
1044
+ 'tunnel is configured or TAPFLOW_TUNNEL_PORT names a port.');
1045
+ this.warnedTrustedProxyWithoutXff = true;
1046
+ }
900
1047
  warnProxyMisconfigOnce(socketAddr, forwardedFor) {
901
1048
  if (this.warnedProxyMisconfig)
902
1049
  return;
@@ -906,12 +1053,19 @@ export class RelayServer {
906
1053
  if (a === '::1' || a.startsWith('127.')) {
907
1054
  logger.warn('Received X-Forwarded-For from a loopback connection but TAPFLOW_TRUSTED_PROXIES is unset. ' +
908
1055
  'If a same-host reverse proxy or `tailscale serve` forwards to this port, every client it forwards is ' +
909
- 'treated as localhost (unauthenticated). Point it at the tunnel port instead (TAPFLOW_TUNNEL_PORT, ' +
910
- 'default 4001), or set TAPFLOW_TRUSTED_PROXIES so the real client IP is used.');
1056
+ 'treated as localhost (unauthenticated). Point it at the tunnel port instead (open only when a tunnel ' +
1057
+ 'is configured or TAPFLOW_TUNNEL_PORT names a port), or set TAPFLOW_TRUSTED_PROXIES so the real ' +
1058
+ 'client IP is used.');
911
1059
  this.warnedProxyMisconfig = true;
912
1060
  }
913
1061
  }
914
1062
  handleConnection(ws, request) {
1063
+ // **First, before anything can return.** A frame that breaks the protocol makes `ws` emit `'error'` on
1064
+ // this socket (a reset it handles itself), and an EventEmitter with no `'error'` listener throws: from
1065
+ // inside `ws`'s receiver, with nothing above it to catch. One client could end the relay for everyone,
1066
+ // signed in or not. `ws` closes the socket itself after emitting, so the `'close'` handler below still runs.
1067
+ // Debug, not warn: any client can produce this at will, and the close is already logged where it matters.
1068
+ ws.on('error', (err) => logger.debug(`WebSocket error: ${err.message}`));
915
1069
  const socketAddr = this.remoteAddressOf(request);
916
1070
  const xff = request.headers['x-forwarded-for'];
917
1071
  const forwardedFor = Array.isArray(xff) ? xff[0] : xff;
@@ -919,6 +1073,7 @@ export class RelayServer {
919
1073
  // A proxy in front of the tunnel port adds the header too, and there it changes nothing about auth.
920
1074
  if (!viaTunnel)
921
1075
  this.warnProxyMisconfigOnce(socketAddr, forwardedFor);
1076
+ this.warnTrustedProxyWithoutXffOnce(request);
922
1077
  const { addr, isLocal } = resolveClientAddress({
923
1078
  socketAddr,
924
1079
  forwardedFor,
@@ -952,6 +1107,10 @@ export class RelayServer {
952
1107
  if (pat)
953
1108
  touchPat(pat.patId);
954
1109
  userId = cookie?.userId ?? pat?.userId;
1110
+ // A loopback cookie is not re-judged later — `principals` below records remote sockets only — so a
1111
+ // watch on loopback lasts until the socket closes even if that user is removed. The same is true of
1112
+ // everything else such a socket does; watching adds nothing to it.
1113
+ this.mayWatch.set(ws, cookie !== null || (!isLocal && pat !== null && pat.scopes.includes(VIEW_SCOPE)));
955
1114
  if (!isLocal) {
956
1115
  if (cookie)
957
1116
  this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp, pwv: cookie.pwv });
@@ -994,43 +1153,73 @@ export class RelayServer {
994
1153
  if (this.revokedSockets.has(ws))
995
1154
  return;
996
1155
  if (isBinary) {
997
- // Binary frames arrive on the dedicated stream WS, route to the session's browser
1156
+ // Binary frames arrive on the dedicated stream WS, route to the session's browser and watchers.
998
1157
  const session = this.sessions.getByStreamSocket(ws);
999
- if (session?.browserSocket) {
1000
- const frameBuf = data;
1001
- // Audio rides the same socket (codec-tagged). Route it through a sender that YIELDS to
1002
- // video — it drops audio unless the socket is near-empty, so audio never inflates
1003
- // bufferedAmount enough to trip the video backpressure path. Must branch before the
1004
- // video dropper, which would (wrongly) treat audio as a droppable P-frame.
1005
- if (hasEnvelope(frameBuf) && readEnvelopeFlags(frameBuf).codec === CODEC_AUDIO) {
1006
- patchRelayedAt(frameBuf, Date.now());
1007
- let onAudioDrop = this.audioDropHandlers.get(session.id);
1008
- if (!onAudioDrop) {
1009
- onAudioDrop = createRateLimitedDropWarn(logger, `${session.id} audio`);
1010
- this.audioDropHandlers.set(session.id, onAudioDrop);
1011
- }
1012
- sendAudioYieldingToVideo(session.browserSocket, frameBuf, onAudioDrop);
1158
+ if (!session)
1159
+ return;
1160
+ // **Not gated on the holder.** Watchers keep receiving frames while the holder is away inside its
1161
+ // reconnect grace, which is when `browserSocket` is null.
1162
+ const watchers = this.sessions.watchersOf(session.id);
1163
+ // A holder that said it does not read the stream (`stream: false`) is sent nothing: the MCP server and a
1164
+ // runner that is not recording discard every frame, and an iOS session is ~34 Mbit/s of JPEG to do it with.
1165
+ const holder = session.holderStream ? session.browserSocket : null;
1166
+ if (!holder && watchers.length === 0)
1167
+ return;
1168
+ const frameBuf = data;
1169
+ // Audio rides the same socket (codec-tagged). Route it through a sender that YIELDS to
1170
+ // video — it drops audio unless the socket is near-empty, so audio never inflates
1171
+ // bufferedAmount enough to trip the video backpressure path. Must branch before the
1172
+ // video dropper, which would (wrongly) treat audio as a droppable P-frame.
1173
+ //
1174
+ // **The holder's alone.** A watch page has no audio control, and audio a teammate did not ask
1175
+ // to hear should not start playing in their tab.
1176
+ if (hasEnvelope(frameBuf) && readEnvelopeFlags(frameBuf).codec === CODEC_AUDIO) {
1177
+ if (!holder)
1013
1178
  return;
1179
+ patchRelayedAt(frameBuf, Date.now());
1180
+ let onAudioDrop = this.audioDropHandlers.get(session.id);
1181
+ if (!onAudioDrop) {
1182
+ onAudioDrop = createRateLimitedDropWarn(logger, `${session.id} audio`);
1183
+ this.audioDropHandlers.set(session.id, onAudioDrop);
1014
1184
  }
1015
- let onDrop = this.dropHandlers.get(session.id);
1016
- if (!onDrop) {
1017
- onDrop = createRateLimitedDropWarn(logger, session.id);
1018
- this.dropHandlers.set(session.id, onDrop);
1019
- }
1185
+ sendAudioYieldingToVideo(holder, frameBuf, onAudioDrop);
1186
+ return;
1187
+ }
1188
+ let onDrop = this.dropHandlers.get(session.id);
1189
+ if (!onDrop) {
1190
+ onDrop = createRateLimitedDropWarn(logger, session.id);
1191
+ this.dropHandlers.set(session.id, onDrop);
1192
+ }
1193
+ const requestIdr = this.idrRequester(session.id);
1194
+ // JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
1195
+ // Patched once: every recipient is sent the same buffer, so it carries one relay timestamp.
1196
+ let isKeyframe = true;
1197
+ if (hasEnvelope(frameBuf)) {
1198
+ patchRelayedAt(frameBuf, Date.now());
1199
+ const flags = readEnvelopeFlags(frameBuf);
1200
+ isKeyframe = flags.codec === CODEC_JPEG || flags.keyframe;
1201
+ }
1202
+ if (holder) {
1020
1203
  let dropper = this.droppers.get(session.id);
1021
1204
  if (!dropper) {
1022
1205
  dropper = createKeyframeAwareSender();
1023
1206
  this.droppers.set(session.id, dropper);
1024
1207
  }
1025
- const requestIdr = this.idrRequester(session.id);
1026
- // JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
1027
- let isKeyframe = true;
1028
- if (hasEnvelope(frameBuf)) {
1029
- patchRelayedAt(frameBuf, Date.now());
1030
- const flags = readEnvelopeFlags(frameBuf);
1031
- isKeyframe = flags.codec === CODEC_JPEG || flags.keyframe;
1208
+ dropper.send(holder, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
1209
+ }
1210
+ // **No IDR request on a watcher's drop**, and its own drop warning. An IDR changes the encoder's
1211
+ // output for everyone, so a watcher on a slow link asking for one on every drain would cost the
1212
+ // holder up to two forced keyframes a second — the slow-watcher-hurts-holder case the separate
1213
+ // sender exists to prevent, by another route. A watcher resyncs on the next periodic keyframe.
1214
+ if (watchers.length > 0) {
1215
+ let onWatcherDrop = this.dropHandlers.get(`${session.id} watchers`);
1216
+ if (!onWatcherDrop) {
1217
+ onWatcherDrop = createRateLimitedDropWarn(logger, `${session.id} watchers`);
1218
+ this.dropHandlers.set(`${session.id} watchers`, onWatcherDrop);
1219
+ }
1220
+ for (const w of watchers) {
1221
+ this.watcherDropper(w, session.id).send(w, frameBuf, this.backpressureBytes, isKeyframe, onWatcherDrop);
1032
1222
  }
1033
- dropper.send(session.browserSocket, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
1034
1223
  }
1035
1224
  return;
1036
1225
  }
@@ -1085,7 +1274,19 @@ export class RelayServer {
1085
1274
  logger.error(`route failed for ${inbound.msg.type}:`, e);
1086
1275
  }
1087
1276
  });
1088
- ws.on('close', () => {
1277
+ ws.on('close', (code, reason) => {
1278
+ // Not while stopping: the relay ending every socket itself says nothing about the agents.
1279
+ if (this.wsRoles.get(ws) === 'agent' && !this.stopping) {
1280
+ const name = this.agentNames.get(ws) ?? 'unknown';
1281
+ const explained = this.explainedCloses.get(ws);
1282
+ if (explained === 'replaced') {
1283
+ this.pushLog(`agent socket replaced: ${name} registered again on a new connection`);
1284
+ }
1285
+ else if (!explained) {
1286
+ const why = reason.length > 0 ? ` ${reason.toString()}` : '';
1287
+ this.pushLog(`agent socket closed: ${name} (code ${code}${why}, last pong ${this.pongAgeSeconds(ws)}s ago)`);
1288
+ }
1289
+ }
1089
1290
  this.wsRoles.delete(ws);
1090
1291
  this.wsExternal.delete(ws);
1091
1292
  this.forgetShutdownRequesters(ws);
@@ -1102,6 +1303,9 @@ export class RelayServer {
1102
1303
  const streamSession = this.sessions.getByStreamSocket(ws);
1103
1304
  if (streamSession)
1104
1305
  this.sessions.clearStreamSocket(streamSession.id);
1306
+ // A watcher leaving changes nothing for the session it watched — no release, no timer, no IDR.
1307
+ this.sessions.removeWatcherSocket(ws);
1308
+ this.watcherDroppers.delete(ws);
1105
1309
  // Browser socket disconnected → release **every** session it held, each with its own idle timer.
1106
1310
  //
1107
1311
  // `getByBrowserSocket` used to answer with one session because the index held one, and a socket
@@ -1116,6 +1320,20 @@ export class RelayServer {
1116
1320
  return;
1117
1321
  for (const held of this.sessions.getByBrowserSocket(ws)) {
1118
1322
  this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
1323
+ this.tellWatchers(held.id, { type: 'watch:holder-left', sessionId: held.id });
1324
+ }
1325
+ // A run is its runner's socket: the flow runner does not reconnect, so the last socket with its key
1326
+ // closing is the end of that run, finished or not. Not done while stopping (above) — a relay going
1327
+ // down has not seen the runner go, and the runner may still finish the run against the next one.
1328
+ //
1329
+ // Caught because this is an event listener: a database fault here (a full disk) would be uncaught and end
1330
+ // the relay — the rule `handleConnection` states for its own reads. A run missed here is closed by
1331
+ // `settleOrphanedRuns` on the next read.
1332
+ try {
1333
+ abandonRunsOf(this.ownerOf(ws), this.runHolders);
1334
+ }
1335
+ catch (e) {
1336
+ logger.error('could not close the runs of a disconnected runner:', e);
1119
1337
  }
1120
1338
  });
1121
1339
  }
@@ -1284,6 +1502,26 @@ export class RelayServer {
1284
1502
  case 'session:start':
1285
1503
  this.handleSessionStart(ws, msg);
1286
1504
  break;
1505
+ case 'session:activity': {
1506
+ // A holder's name for what it is about to do (A2), for its watchers only. Unanswered either way, so an
1507
+ // unowned one is simply dropped; the schema already capped its length. **Its content is trusted, not
1508
+ // checked**: the rule that it carries nothing typed binds the sender (`activityLabel` is the only producer),
1509
+ // and a holder that broke it could show no more than it already can on the screen its watchers see.
1510
+ const session = this.sessions.get(msg.sessionId);
1511
+ const label = msg.label.trim();
1512
+ if (session && label && this.ownsSession(ws, session))
1513
+ this.tellWatchersAction(session.id, { kind: 'label', label });
1514
+ break;
1515
+ }
1516
+ case 'watch:start':
1517
+ this.handleWatchStart(ws, msg);
1518
+ break;
1519
+ // Unanswered, like `session:leave`: nobody waits on it, and closing the socket does the same.
1520
+ case 'watch:stop': {
1521
+ if (this.sessions.removeWatcher(msg.sessionId, ws))
1522
+ this.forgetWatcherDropper(ws, msg.sessionId);
1523
+ break;
1524
+ }
1287
1525
  // ── the two session commands, gated but not answered ───────────────────────────────────────
1288
1526
  //
1289
1527
  // Both destroy state a viewer depends on, and until L5c both acted on the strength of the session
@@ -1317,6 +1555,10 @@ export class RelayServer {
1317
1555
  if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
1318
1556
  this.sessions.clearBrowser(msg.sessionId);
1319
1557
  this.forgetSessionState(msg.sessionId);
1558
+ // **A leave is the end, not a blip.** The holder said it is done — a flow run finishing, an MCP
1559
+ // `disconnect_device` — so the watch ends. `watch:holder-left` is for a socket that dropped, which
1560
+ // may come back; sent here it left a watcher of every finished CI run waiting forever.
1561
+ this.sessions.endWatches(msg.sessionId, 'session-ended');
1320
1562
  }
1321
1563
  break;
1322
1564
  }
@@ -1352,6 +1594,7 @@ export class RelayServer {
1352
1594
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1353
1595
  session.browserSocket.send(JSON.stringify(raw));
1354
1596
  }
1597
+ this.forwardToWatchers(session.id, raw);
1355
1598
  break;
1356
1599
  }
1357
1600
  case 'device:postures': {
@@ -1366,6 +1609,7 @@ export class RelayServer {
1366
1609
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1367
1610
  session.browserSocket.send(JSON.stringify(raw));
1368
1611
  }
1612
+ this.forwardToWatchers(session.id, raw);
1369
1613
  break;
1370
1614
  }
1371
1615
  case 'session:deviceInfo': {
@@ -1377,6 +1621,7 @@ export class RelayServer {
1377
1621
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1378
1622
  session.browserSocket.send(JSON.stringify(raw));
1379
1623
  }
1624
+ this.forwardToWatchers(session.id, raw);
1380
1625
  break;
1381
1626
  }
1382
1627
  case 'device:booting': {
@@ -1388,6 +1633,7 @@ export class RelayServer {
1388
1633
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1389
1634
  session.browserSocket.send(JSON.stringify(raw));
1390
1635
  }
1636
+ this.forwardToWatchers(session.id, raw);
1391
1637
  break;
1392
1638
  }
1393
1639
  case 'device:boot-error': {
@@ -1441,6 +1687,7 @@ export class RelayServer {
1441
1687
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1442
1688
  session.browserSocket.send(JSON.stringify(raw));
1443
1689
  }
1690
+ this.forwardToWatchers(session.id, raw);
1444
1691
  break;
1445
1692
  }
1446
1693
  case 'app:install-done':
@@ -1525,6 +1772,7 @@ export class RelayServer {
1525
1772
  // presence check the old line carried is gone because the schema requires the payload — and
1526
1773
  // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
1527
1774
  msg.payload['external'] = this.wsExternal.get(ws) ?? false;
1775
+ msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
1528
1776
  // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
1529
1777
  // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
1530
1778
  this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
@@ -1547,6 +1795,18 @@ export class RelayServer {
1547
1795
  // command the relay never answers. `mcp-server`'s `shutdownDevice` waits 30s on `device:shutdown-done`
1548
1796
  // and then reports `Request timed out` with no cause, which is the silence, not a diagnosis.
1549
1797
  const session = this.sessions.get(msg.sessionId);
1798
+ // A watcher's gate comes first: `mayShutDown` passes anyone while the session is unheld, and the
1799
+ // holder's reconnect grace is exactly that — so a watch page closing at that moment would power
1800
+ // off the device an agent is in the middle of testing.
1801
+ if (session && this.sessions.isWatching(ws, session.id)) {
1802
+ this.sendTo(ws, {
1803
+ type: 'device:shutdown-error',
1804
+ sessionId: msg.sessionId,
1805
+ ...(msg.requestId === undefined ? {} : { requestId: msg.requestId }),
1806
+ message: 'This connection is watching the session and cannot shut its device down.',
1807
+ });
1808
+ break;
1809
+ }
1550
1810
  if (session && !this.mayShutDown(ws, session)) {
1551
1811
  this.sendTo(ws, {
1552
1812
  type: 'device:shutdown-error',
@@ -1605,6 +1865,8 @@ export class RelayServer {
1605
1865
  const target = this.dispatchTarget(ws, msg.sessionId);
1606
1866
  if (target.ok) {
1607
1867
  target.session.agentSocket.send(JSON.stringify(msg));
1868
+ // No URL: a deep link can carry a token, and watchers are the whole team.
1869
+ this.tellWatchersAction(msg.sessionId, { kind: 'open-url' });
1608
1870
  }
1609
1871
  else {
1610
1872
  this.sendTo(ws, {
@@ -1622,6 +1884,7 @@ export class RelayServer {
1622
1884
  const target = this.dispatchTarget(ws, msg.sessionId);
1623
1885
  if (target.ok) {
1624
1886
  target.session.agentSocket.send(JSON.stringify(msg));
1887
+ this.tellWatchersAction(msg.sessionId, { kind: 'clear-state' });
1625
1888
  }
1626
1889
  else {
1627
1890
  this.sendTo(ws, {
@@ -1920,6 +2183,7 @@ export class RelayServer {
1920
2183
  for (const s of sessions) {
1921
2184
  if (s.browserSocket)
1922
2185
  this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
2186
+ this.tellWatchers(s.id, { type: 'session:agent-away', sessionId: s.id });
1923
2187
  }
1924
2188
  const timer = setTimeout(() => {
1925
2189
  this.agentHolds.delete(ws);
@@ -2040,6 +2304,7 @@ export class RelayServer {
2040
2304
  // gone and its in-flight screenshots would be undiscoverable — reject them here instead.
2041
2305
  // The rebound sessions have already moved off `old`, so this no longer covers them.
2042
2306
  this.evictAgentSocket(old, 'replaced');
2307
+ this.explainedCloses.set(old, 'replaced');
2043
2308
  old.terminate();
2044
2309
  }
2045
2310
  }
@@ -2084,6 +2349,7 @@ export class RelayServer {
2084
2349
  if (s?.browserSocket) {
2085
2350
  this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
2086
2351
  }
2352
+ this.tellWatchers(sessionId, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
2087
2353
  }
2088
2354
  // After the rebound frames, so the client records `needsReboot` before the error below
2089
2355
  // arrives and classifies it with the cause attached. Only boots tied to the replaced
@@ -2097,7 +2363,9 @@ export class RelayServer {
2097
2363
  // transition, matching the disconnect line in evictAgentSocket.
2098
2364
  // `||`, not `??`: the schema defaults both of these to `''` for an agent that omits them, so `??`
2099
2365
  // would print an empty name and an empty platform where this used to print `unknown`.
2100
- logger.info(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
2366
+ this.agentNames.set(ws, msg.agentName || msg.agentId || 'unknown');
2367
+ // Through `pushLog` too, so `tapflow logs` shows an agent coming back after the line about it leaving.
2368
+ this.pushLog(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
2101
2369
  }
2102
2370
  /** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
2103
2371
  *
@@ -2112,6 +2380,17 @@ export class RelayServer {
2112
2380
  this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session not found', reason: 'session-not-found' });
2113
2381
  return;
2114
2382
  }
2383
+ // **A watcher may not take the session it watches.** Inside the holder's reconnect grace the session
2384
+ // is unheld, so without this a watch page that re-sent `session:start` — a reused viewer path, a
2385
+ // reconnect — would become the holder, and the agent's own re-join would then be refused as busy.
2386
+ // `session-busy` because that is what the caller must act on: someone else's session, in use.
2387
+ if (this.sessions.isWatching(ws, session.id)) {
2388
+ this.sendTo(ws, {
2389
+ type: 'error', sessionId: msg.sessionId, reason: 'session-busy',
2390
+ message: 'This connection is watching the session. Stop watching to join it.',
2391
+ });
2392
+ return;
2393
+ }
2115
2394
  // Occupancy first, because it is the more specific answer and both can be true at once. A tester
2116
2395
  // joining a device someone else has open on a loaded Mac was being told "this Mac is overloaded,
2117
2396
  // pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
@@ -2143,7 +2422,9 @@ export class RelayServer {
2143
2422
  }
2144
2423
  }
2145
2424
  try {
2146
- const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h));
2425
+ const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h),
2426
+ // Recorded by the join itself, so it holds before the agent-away return below and across a rebind.
2427
+ { kind: msg.clientKind, label: this.holderLabel(ws), ...(msg.stream === false ? { stream: false } : {}) });
2147
2428
  if (!joined.ok) {
2148
2429
  // Both of these are answered above, so arriving here means the state moved between that check and
2149
2430
  // this call. They are **values now rather than throws** (#515), and that is what fixes the defect:
@@ -2174,6 +2455,7 @@ export class RelayServer {
2174
2455
  // deliberately instead of inferring anything from a timeout.
2175
2456
  this.sendTo(ws, {
2176
2457
  type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
2458
+ ...(isAiClientKind(msg.clientKind) ? this.watchUrlField(msg.sessionId) : {}),
2177
2459
  });
2178
2460
  if (agentAway) {
2179
2461
  // Joining into a held session. Refusing instead would be worse than it sounds: the viewer
@@ -2218,7 +2500,10 @@ export class RelayServer {
2218
2500
  // the P-frames around it, so the amplification lands on the tester who is actually watching. The
2219
2501
  // backpressure path already throttles this exact message per session; sharing that requester is
2220
2502
  // what keeps the two callers from having two policies.
2221
- this.idrRequester(session.id)();
2503
+ // Not for a holder that takes no frames: the keyframe would go to watchers alone, and it would spend the
2504
+ // throttle window a watcher's own `watch:start` request needs.
2505
+ if (session.holderStream)
2506
+ this.idrRequester(session.id)();
2222
2507
  // And ask what the device's network is doing (#614). `NetworkState` is agent-produced and is not
2223
2508
  // among the three replayed above, so without this a viewer that reconnects has no way to learn
2224
2509
  // whether the device is offline and its control renders in a guessed position.
@@ -2242,6 +2527,150 @@ export class RelayServer {
2242
2527
  this.networkStateRequester(session.id)();
2243
2528
  }
2244
2529
  }
2530
+ /**
2531
+ * Admit `ws` as a read-only watcher, or say why not. The order of the refusals is the order a viewer
2532
+ * can act on them: a connection that may never watch is told so before it learns anything about the
2533
+ * session.
2534
+ */
2535
+ handleWatchStart(ws, msg) {
2536
+ const refuse = (reason, message) => this.sendTo(ws, { type: 'watch:refused', sessionId: msg.sessionId, reason, message });
2537
+ if (!this.mayWatch.get(ws))
2538
+ return refuse('not-permitted', 'Sign in to watch a session.');
2539
+ const session = this.sessions.get(msg.sessionId);
2540
+ if (!session)
2541
+ return refuse('session-not-found', 'Session not found');
2542
+ if (session.owner === null || !isAiClientKind(session.holderKind)) {
2543
+ return refuse('not-watchable', 'Only a session an AI client is driving can be watched.');
2544
+ }
2545
+ // The holder already receives everything. Watching too would deliver each frame twice to one socket,
2546
+ // and the watcher gates would then refuse the holder's own shutdown and re-join.
2547
+ if (this.ownsSession(ws, session))
2548
+ return refuse('not-watchable', 'This connection already holds the session.');
2549
+ if (this.sessions.addWatcher(session.id, ws, session.owner) === 'full') {
2550
+ return refuse('watchers-full', 'This session already has as many watchers as it allows.');
2551
+ }
2552
+ this.sendTo(ws, { type: 'watch:started', sessionId: session.id });
2553
+ // The same replay a re-joining viewer gets, for the same reason: a watch can begin mid-session.
2554
+ if (session.agentSocket.readyState !== WebSocket.OPEN) {
2555
+ this.sendTo(ws, { type: 'session:agent-away', sessionId: session.id });
2556
+ return;
2557
+ }
2558
+ if (session.chromeData)
2559
+ this.sendTo(ws, { type: 'session:chrome', sessionId: session.id, payload: session.chromeData });
2560
+ if (session.deviceInfo)
2561
+ this.sendTo(ws, { type: 'session:deviceInfo', sessionId: session.id, payload: session.deviceInfo });
2562
+ if (session.postures)
2563
+ this.sendTo(ws, { type: 'device:postures', sessionId: session.id, payload: session.postures });
2564
+ if (session.readySent) {
2565
+ this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
2566
+ // Through the session's throttled requester, as the re-join does: a watcher arriving mid-GOP needs
2567
+ // a keyframe, and a socket starting watches repeatedly must not turn into a stream of IDRs.
2568
+ this.idrRequester(session.id)();
2569
+ }
2570
+ }
2571
+ /** The keyframe-aware sender for one watcher of one session — see `watcherDroppers`. */
2572
+ watcherDropper(ws, sessionId) {
2573
+ let bySession = this.watcherDroppers.get(ws);
2574
+ if (!bySession) {
2575
+ bySession = new Map();
2576
+ this.watcherDroppers.set(ws, bySession);
2577
+ }
2578
+ let dropper = bySession.get(sessionId);
2579
+ if (!dropper) {
2580
+ dropper = createKeyframeAwareSender();
2581
+ bySession.set(sessionId, dropper);
2582
+ }
2583
+ return dropper;
2584
+ }
2585
+ forgetWatcherDropper(ws, sessionId) {
2586
+ const bySession = this.watcherDroppers.get(ws);
2587
+ bySession?.delete(sessionId);
2588
+ if (bySession?.size === 0)
2589
+ this.watcherDroppers.delete(ws);
2590
+ }
2591
+ /**
2592
+ * Forward an agent's frame to the session's watchers. **Only the messages that describe the device**
2593
+ * call this — chrome, device info, postures, booting, ready — never a reply to something the holder
2594
+ * asked. An ack, a clipboard payload or a network state is the holder's, and a watcher shown one would
2595
+ * learn what the agent is doing on a channel it was never admitted to.
2596
+ */
2597
+ forwardToWatchers(sessionId, raw) {
2598
+ const watchers = this.sessions.watchersOf(sessionId);
2599
+ if (watchers.length === 0)
2600
+ return;
2601
+ const frame = JSON.stringify(raw);
2602
+ for (const w of watchers)
2603
+ if (w.readyState === WebSocket.OPEN)
2604
+ w.send(frame);
2605
+ }
2606
+ /**
2607
+ * What the holder just did, as its watchers are told it (A2): only for an input the relay passed on to the agent,
2608
+ * so a refused one is never shown. **Built, never forwarded**: the raw `input:type` carries the text typed, and a
2609
+ * watcher gets a count.
2610
+ */
2611
+ tellWatchersInput(session, msg) {
2612
+ let action = null;
2613
+ switch (msg.type) {
2614
+ case 'input:touch:end': {
2615
+ const g = session.gesture;
2616
+ session.gesture = null;
2617
+ action = gestureAction(g?.start ?? null, msg.payload ?? g?.last ?? null);
2618
+ break;
2619
+ }
2620
+ case 'input:pinch:end':
2621
+ action = { kind: 'pinch' };
2622
+ break;
2623
+ case 'input:key':
2624
+ action = { kind: 'key', key: keyForWatchers(msg.payload.code) };
2625
+ break;
2626
+ // One press is a down and an up from a client that sends both; told once, on the up.
2627
+ case 'input:button':
2628
+ action = msg.payload.phase === 'down' ? null : { kind: 'button', button: msg.payload.name };
2629
+ break;
2630
+ case 'input:type':
2631
+ action = { kind: 'type', chars: [...msg.payload.text].length };
2632
+ break;
2633
+ }
2634
+ if (action)
2635
+ this.tellWatchersAction(session.id, action);
2636
+ }
2637
+ tellWatchersAction(sessionId, action) {
2638
+ const watchers = this.sessions.watchersOf(sessionId);
2639
+ if (watchers.length === 0)
2640
+ return;
2641
+ for (const w of watchers)
2642
+ this.sendTo(w, { type: 'watch:action', sessionId, at: Date.now(), action });
2643
+ }
2644
+ /** The relay-originated half of what watchers hear: holder left, agent away, agent rebound. */
2645
+ tellWatchers(sessionId, msg) {
2646
+ for (const w of this.sessions.watchersOf(sessionId))
2647
+ this.sendTo(w, msg);
2648
+ }
2649
+ /** Who to show behind a holder: the signed-in user's email, or `local` for a loopback client with no
2650
+ * credential. A label for people only — nothing reads it back. */
2651
+ holderLabel(ws) {
2652
+ const user = this.userOf(ws);
2653
+ const id = Number(user);
2654
+ if (!Number.isInteger(id))
2655
+ return 'local';
2656
+ try {
2657
+ const row = getDb().prepare('SELECT email FROM users WHERE id = ?').get(id);
2658
+ return row?.email ?? 'unknown';
2659
+ }
2660
+ catch {
2661
+ return 'unknown';
2662
+ }
2663
+ }
2664
+ /**
2665
+ * The dashboard page that watches a session — **only at an address a teammate's browser can open**, the
2666
+ * gate invite links take (`forTeammates`). With no tunnel and no `relay.url` the only base this relay has
2667
+ * is localhost, which is wrong for a client on another machine; the field is left out and the client
2668
+ * builds the link from the address it dialled, which the person running it can always open.
2669
+ */
2670
+ watchUrlField(sessionId) {
2671
+ const base = forTeammates(resolvePublicBaseUrl(config, this.options.tunnel));
2672
+ return base === null ? {} : { watchUrl: `${base}/automation/sessions/${encodeURIComponent(sessionId)}` };
2673
+ }
2245
2674
  /** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
2246
2675
  * of the close handler so the release loop there reads as one line per session. */
2247
2676
  idleShutdown(sessionId) {
@@ -2391,6 +2820,12 @@ export class RelayServer {
2391
2820
  }
2392
2821
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
2393
2822
  session.agentSocket.send(JSON.stringify(msg));
2823
+ // Where a touch began and last was, for its end to be told to watchers (A2). Moves are not sent on: one
2824
+ // action per gesture, at its end.
2825
+ if (msg.type === 'input:touch:start')
2826
+ session.gesture = { start: msg.payload, last: msg.payload };
2827
+ else if (msg.type === 'input:touch:move' && session.gesture)
2828
+ session.gesture.last = msg.payload;
2394
2829
  }
2395
2830
  }
2396
2831
  /** The five inputs an ack answers, so the five that carry a required correlator.
@@ -2418,6 +2853,7 @@ export class RelayServer {
2418
2853
  }
2419
2854
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
2420
2855
  session.agentSocket.send(JSON.stringify(msg));
2856
+ this.tellWatchersInput(session, msg);
2421
2857
  return;
2422
2858
  }
2423
2859
  // A request that cannot be dispatched. Two situations reach here and only one is the agent's fault:
@@ -2585,6 +3021,7 @@ export class RelayServer {
2585
3021
  buildBytes: bytes,
2586
3022
  },
2587
3023
  });
3024
+ this.tellWatchersAction(sessionId, { kind: 'install' });
2588
3025
  }
2589
3026
  /**
2590
3027
  * Says once, per agent, that this agent cannot fetch builds over HTTP.
@@ -2641,6 +3078,7 @@ export class RelayServer {
2641
3078
  requestId,
2642
3079
  payload: { bundleId: build.bundle_id },
2643
3080
  });
3081
+ this.tellWatchersAction(sessionId, { kind: 'launch' });
2644
3082
  }
2645
3083
  async handleGetScreenshot(req, res, params) {
2646
3084
  if (!requireViewAuth(req, res))
@@ -2791,6 +3229,7 @@ export class RelayServer {
2791
3229
  this.resourceBuffers.clear();
2792
3230
  }
2793
3231
  async handleRequest(req, res) {
3232
+ this.warnTrustedProxyWithoutXffOnce(req);
2794
3233
  const corsHeaders = resolveCorsHeaders(req.headers.origin, this.corsAllowed);
2795
3234
  if (corsHeaders) {
2796
3235
  for (const [k, v] of Object.entries(corsHeaders))