@tapflowio/relay 0.28.1 → 0.29.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 (190) hide show
  1. package/dist/RelayServer.d.ts +75 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +496 -49
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +41 -2
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +31 -1
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/admission/HostBootGate.d.ts +181 -0
  10. package/dist/admission/HostBootGate.d.ts.map +1 -0
  11. package/dist/admission/HostBootGate.js +388 -0
  12. package/dist/admission/HostBootGate.js.map +1 -0
  13. package/dist/admission/capacity.d.ts +51 -0
  14. package/dist/admission/capacity.d.ts.map +1 -0
  15. package/dist/admission/capacity.js +49 -0
  16. package/dist/admission/capacity.js.map +1 -0
  17. package/dist/admission/classifyBoot.d.ts +17 -0
  18. package/dist/admission/classifyBoot.d.ts.map +1 -0
  19. package/dist/admission/classifyBoot.js +23 -0
  20. package/dist/admission/classifyBoot.js.map +1 -0
  21. package/dist/admission/hostKey.d.ts +10 -0
  22. package/dist/admission/hostKey.d.ts.map +1 -0
  23. package/dist/admission/hostKey.js +13 -0
  24. package/dist/admission/hostKey.js.map +1 -0
  25. package/dist/admission/leaseChoice.d.ts +38 -0
  26. package/dist/admission/leaseChoice.d.ts.map +1 -0
  27. package/dist/admission/leaseChoice.js +40 -0
  28. package/dist/admission/leaseChoice.js.map +1 -0
  29. package/dist/admission/settle.d.ts +35 -0
  30. package/dist/admission/settle.d.ts.map +1 -0
  31. package/dist/admission/settle.js +41 -0
  32. package/dist/admission/settle.js.map +1 -0
  33. package/dist/api/runs.d.ts +21 -2
  34. package/dist/api/runs.d.ts.map +1 -1
  35. package/dist/api/runs.js +181 -26
  36. package/dist/api/runs.js.map +1 -1
  37. package/dist/migrations/017_flow_run_skipped.sql +34 -0
  38. package/dist/migrations/018_flow_run_service_tier.sql +6 -0
  39. package/dist/migrations/019_flow_run_device_reset.sql +7 -0
  40. package/package.json +4 -4
  41. package/public/assets/{AISessionWatch-C2h4NZ_I.js → AISessionWatch-BSBkpTom.js} +7 -7
  42. package/public/assets/AISessionWatch-BSBkpTom.js.br +0 -0
  43. package/public/assets/AISessionWatch-BSBkpTom.js.gz +0 -0
  44. package/public/assets/{AppCenter-S1y-8_7l.js → AppCenter-DD_6LQx-.js} +6 -6
  45. package/public/assets/AppCenter-DD_6LQx-.js.br +0 -0
  46. package/public/assets/AppCenter-DD_6LQx-.js.gz +0 -0
  47. package/public/assets/{Default-C-l1O-g9.js → Default-CQ3Ae2mK.js} +1 -1
  48. package/public/assets/Default-CQ3Ae2mK.js.br +0 -0
  49. package/public/assets/Default-CQ3Ae2mK.js.gz +0 -0
  50. package/public/assets/{Invite-BCndVxaO.js → Invite-D1uNMm0-.js} +1 -1
  51. package/public/assets/Invite-D1uNMm0-.js.br +0 -0
  52. package/public/assets/Invite-D1uNMm0-.js.gz +0 -0
  53. package/public/assets/{MacResources-41cFDgE1.js → MacResources-D2UeRbKL.js} +1 -1
  54. package/public/assets/MacResources-D2UeRbKL.js.br +0 -0
  55. package/public/assets/MacResources-D2UeRbKL.js.gz +0 -0
  56. package/public/assets/QASession-d0eOjXXo.js +81 -0
  57. package/public/assets/QASession-d0eOjXXo.js.br +0 -0
  58. package/public/assets/QASession-d0eOjXXo.js.gz +0 -0
  59. package/public/assets/{ResetPassword-8rj7ASt1.js → ResetPassword-B-4Uyfwr.js} +1 -1
  60. package/public/assets/ResetPassword-B-4Uyfwr.js.br +0 -0
  61. package/public/assets/ResetPassword-B-4Uyfwr.js.gz +0 -0
  62. package/public/assets/RunDetail-C50RjsuM.js +16 -0
  63. package/public/assets/RunDetail-C50RjsuM.js.br +0 -0
  64. package/public/assets/RunDetail-C50RjsuM.js.gz +0 -0
  65. package/public/assets/Runs-A7RcDLgO.js +6 -0
  66. package/public/assets/Runs-A7RcDLgO.js.br +0 -0
  67. package/public/assets/Runs-A7RcDLgO.js.gz +0 -0
  68. package/public/assets/{Setup-DZrhA_7b.js → Setup-DK6TZbwh.js} +1 -1
  69. package/public/assets/Setup-DK6TZbwh.js.br +0 -0
  70. package/public/assets/Setup-DK6TZbwh.js.gz +0 -0
  71. package/public/assets/{Team-CJThCjd3.js → Team-VoGput2p.js} +2 -2
  72. package/public/assets/Team-VoGput2p.js.br +0 -0
  73. package/public/assets/Team-VoGput2p.js.gz +0 -0
  74. package/public/assets/{Tokens-DGURtHWi.js → Tokens-qkcJgunR.js} +1 -1
  75. package/public/assets/Tokens-qkcJgunR.js.br +0 -0
  76. package/public/assets/Tokens-qkcJgunR.js.gz +0 -0
  77. package/public/assets/{aiSession-BW09Jhqz.js → aiSession-U7UqgdSb.js} +6 -6
  78. package/public/assets/aiSession-U7UqgdSb.js.br +0 -0
  79. package/public/assets/aiSession-U7UqgdSb.js.gz +0 -0
  80. package/public/assets/{alert-dialog-t5vlOabq.js → alert-dialog-C0mRms8g.js} +1 -1
  81. package/public/assets/{alert-dialog-t5vlOabq.js.br → alert-dialog-C0mRms8g.js.br} +0 -0
  82. package/public/assets/alert-dialog-C0mRms8g.js.gz +0 -0
  83. package/public/assets/{badge-DrDCTAQQ.js → badge-BjEnOmb0.js} +1 -1
  84. package/public/assets/badge-BjEnOmb0.js.br +0 -0
  85. package/public/assets/badge-BjEnOmb0.js.gz +0 -0
  86. package/public/assets/{dialog-BOFZkCES.js → dialog-Cugbp1C4.js} +1 -1
  87. package/public/assets/dialog-Cugbp1C4.js.br +0 -0
  88. package/public/assets/dialog-Cugbp1C4.js.gz +0 -0
  89. package/public/assets/{field-hint-B9fPUgi7.js → field-hint-qmBa0qqN.js} +1 -1
  90. package/public/assets/field-hint-qmBa0qqN.js.br +0 -0
  91. package/public/assets/field-hint-qmBa0qqN.js.gz +0 -0
  92. package/public/assets/{index-Bixhy5MB.js → index-C_3Q51YJ.js} +39 -39
  93. package/public/assets/index-C_3Q51YJ.js.br +0 -0
  94. package/public/assets/index-C_3Q51YJ.js.gz +0 -0
  95. package/public/assets/{list-view-BpOY1e-d.js → list-view-CyM0QUaH.js} +1 -1
  96. package/public/assets/list-view-CyM0QUaH.js.br +0 -0
  97. package/public/assets/list-view-CyM0QUaH.js.gz +0 -0
  98. package/public/assets/{pencil-ZlrAsWuU.js → pencil-DUZRZB3S.js} +2 -2
  99. package/public/assets/pencil-DUZRZB3S.js.br +0 -0
  100. package/public/assets/pencil-DUZRZB3S.js.gz +0 -0
  101. package/public/assets/{play-4CsGZdQU.js → play-Z1L2Eb91.js} +2 -2
  102. package/public/assets/play-Z1L2Eb91.js.br +0 -0
  103. package/public/assets/play-Z1L2Eb91.js.gz +0 -0
  104. package/public/assets/{runs-CInQb6v1.js → runs-CFQ6M4rx.js} +1 -1
  105. package/public/assets/runs-CFQ6M4rx.js.br +0 -0
  106. package/public/assets/runs-CFQ6M4rx.js.gz +0 -0
  107. package/public/assets/{search-input-G_QpBvPM.js → search-input-B0s9mzRt.js} +2 -2
  108. package/public/assets/search-input-B0s9mzRt.js.br +0 -0
  109. package/public/assets/search-input-B0s9mzRt.js.gz +0 -0
  110. package/public/assets/{select-DDzv2YfY.js → select-CHbE6M4S.js} +3 -3
  111. package/public/assets/select-CHbE6M4S.js.br +0 -0
  112. package/public/assets/select-CHbE6M4S.js.gz +0 -0
  113. package/public/assets/{slider-DE7lQdK4.js → slider-tyb3JvL2.js} +1 -1
  114. package/public/assets/slider-tyb3JvL2.js.br +0 -0
  115. package/public/assets/slider-tyb3JvL2.js.gz +0 -0
  116. package/public/assets/{tabs-Co1N3x_X.js → tabs-Bf7ou-eS.js} +1 -1
  117. package/public/assets/tabs-Bf7ou-eS.js.br +0 -0
  118. package/public/assets/tabs-Bf7ou-eS.js.gz +0 -0
  119. package/public/assets/{tech-label-BMvuJmUf.js → tech-label-KAwM5qZ_.js} +1 -1
  120. package/public/assets/tech-label-KAwM5qZ_.js.br +0 -0
  121. package/public/assets/tech-label-KAwM5qZ_.js.gz +0 -0
  122. package/public/assets/{trash-Cwj2dbgB.js → trash-HEJmY7DQ.js} +3 -3
  123. package/public/assets/trash-HEJmY7DQ.js.br +0 -0
  124. package/public/assets/trash-HEJmY7DQ.js.gz +0 -0
  125. package/public/assets/{video-swsaHrfi.js → video-DdHH0Q0l.js} +2 -2
  126. package/public/assets/video-DdHH0Q0l.js.br +0 -0
  127. package/public/assets/video-DdHH0Q0l.js.gz +0 -0
  128. package/public/index.html +1 -1
  129. package/public/index.html.br +0 -0
  130. package/public/index.html.gz +0 -0
  131. package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
  132. package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
  133. package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
  134. package/public/assets/AppCenter-S1y-8_7l.js.gz +0 -0
  135. package/public/assets/Default-C-l1O-g9.js.br +0 -0
  136. package/public/assets/Default-C-l1O-g9.js.gz +0 -0
  137. package/public/assets/Invite-BCndVxaO.js.br +0 -0
  138. package/public/assets/Invite-BCndVxaO.js.gz +0 -0
  139. package/public/assets/MacResources-41cFDgE1.js.br +0 -0
  140. package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
  141. package/public/assets/QASession-B67RsIPm.js +0 -81
  142. package/public/assets/QASession-B67RsIPm.js.br +0 -0
  143. package/public/assets/QASession-B67RsIPm.js.gz +0 -0
  144. package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
  145. package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
  146. package/public/assets/RunDetail-BIgq9gJ0.js +0 -16
  147. package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
  148. package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
  149. package/public/assets/Runs-DpK5aoBr.js +0 -6
  150. package/public/assets/Runs-DpK5aoBr.js.br +0 -0
  151. package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
  152. package/public/assets/Setup-DZrhA_7b.js.br +0 -0
  153. package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
  154. package/public/assets/Team-CJThCjd3.js.br +0 -0
  155. package/public/assets/Team-CJThCjd3.js.gz +0 -0
  156. package/public/assets/Tokens-DGURtHWi.js.br +0 -0
  157. package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
  158. package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
  159. package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
  160. package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
  161. package/public/assets/badge-DrDCTAQQ.js.br +0 -0
  162. package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
  163. package/public/assets/dialog-BOFZkCES.js.br +0 -0
  164. package/public/assets/dialog-BOFZkCES.js.gz +0 -0
  165. package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
  166. package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
  167. package/public/assets/index-Bixhy5MB.js.br +0 -0
  168. package/public/assets/index-Bixhy5MB.js.gz +0 -0
  169. package/public/assets/list-view-BpOY1e-d.js.br +0 -0
  170. package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
  171. package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
  172. package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
  173. package/public/assets/play-4CsGZdQU.js.br +0 -0
  174. package/public/assets/play-4CsGZdQU.js.gz +0 -0
  175. package/public/assets/runs-CInQb6v1.js.br +0 -0
  176. package/public/assets/runs-CInQb6v1.js.gz +0 -0
  177. package/public/assets/search-input-G_QpBvPM.js.br +0 -0
  178. package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
  179. package/public/assets/select-DDzv2YfY.js.br +0 -0
  180. package/public/assets/select-DDzv2YfY.js.gz +0 -0
  181. package/public/assets/slider-DE7lQdK4.js.br +0 -0
  182. package/public/assets/slider-DE7lQdK4.js.gz +0 -0
  183. package/public/assets/tabs-Co1N3x_X.js.br +0 -0
  184. package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
  185. package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
  186. package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
  187. package/public/assets/trash-Cwj2dbgB.js.br +0 -0
  188. package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
  189. package/public/assets/video-swsaHrfi.js.br +0 -0
  190. package/public/assets/video-swsaHrfi.js.gz +0 -0
@@ -7,7 +7,12 @@ import { randomUUID } from 'crypto';
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
8
  import { SessionManager, isAiClientKind } from './SessionManager.js';
9
9
  import { gestureAction, keyForWatchers } from './lib/watchActions.js';
10
- import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
10
+ import { HostBootGate, GATE_DEFAULTS } from './admission/HostBootGate.js';
11
+ import { defaultCap, idleToFree, planCapacity } from './admission/capacity.js';
12
+ import { classifyBoot } from './admission/classifyBoot.js';
13
+ import { hostKeyOf } from './admission/hostKey.js';
14
+ import { chooseLease } from './admission/leaseChoice.js';
15
+ import { directionOf, parseInbound, readyFacts } from '@tapflowio/protocol/validate';
11
16
  import { Router, json } from './router.js';
12
17
  import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
13
18
  import { AGENT_SCOPE, VIEW_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
@@ -91,7 +96,7 @@ import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
91
96
  import { handleUpdateProfile } from './api/profile.js';
92
97
  import { handleUploadRecording, handleListRecordings, handleDownloadRecording, purgeExpiredRecordings } from './api/recordings.js';
93
98
  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';
99
+ import { abandonRunsOf, handleCreateRun, handleFinishRun, handleReportDevice, handleStartFlow, handleGetFlowScreenshot, handleGetRun, handleListRuns, handleReportFlow, handleUploadFlowScreenshot, purgeExpiredRuns, runScreenshotsDirFor } from './api/runs.js';
95
100
  import { handleListAgents, handleGetAgentResources } from './api/agents.js';
96
101
  const MIME_TYPES = {
97
102
  '.html': 'text/html',
@@ -109,6 +114,15 @@ const MIME_TYPES = {
109
114
  };
110
115
  const _parsedThreshold = parseInt(process.env['TAPFLOW_RESOURCE_THRESHOLD_PERCENT'] ?? '80', 10);
111
116
  const RESOURCE_THRESHOLD = Number.isFinite(_parsedThreshold) ? _parsedThreshold : 80;
117
+ /** How long a device the relay shut down to make room stays out of the automation count without word from its
118
+ * agent. A shutdown takes seconds; past this, assume it did not happen and count the device again. */
119
+ const EVICTION_GRACE_MS = 60_000;
120
+ /** The prose on a refused boot. Clients act on `reason`; this is for the person reading a log. */
121
+ const BOOT_REFUSAL_MESSAGES = {
122
+ 'host-booting': 'Another device is still booting on this Mac. Try again once it has settled.',
123
+ 'host-memory-critical': 'This Mac is critically short of memory. Try again once it has recovered.',
124
+ 'host-at-capacity': 'This Mac already runs as many automation devices as it allows, and all of them are in use.',
125
+ };
112
126
  // Messages that only agents are allowed to send. Authenticated browser sockets
113
127
  // that send any of these are disconnected immediately.
114
128
  // Terminal input messages the MCP client awaits an ack for — if the agent is offline
@@ -322,6 +336,12 @@ export class RelayServer {
322
336
  /** How long a tracked boot lives without an answer. An option so tests can shrink the
323
337
  * 180s horizon; production always takes the default. */
324
338
  pendingBootTtlMs;
339
+ /** One cold boot at a time per Mac (`admission/HostBootGate.ts`). */
340
+ bootGate;
341
+ /** `TAPFLOW_MAX_AUTOMATED_DEVICES`, when it is usable; otherwise each Mac's cap comes from its memory. */
342
+ automatedCapOverride;
343
+ evictionGraceMs;
344
+ bootGateTimer = null;
325
345
  corsAllowed;
326
346
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
327
347
  warnedProxyMisconfig = false;
@@ -390,12 +410,21 @@ export class RelayServer {
390
410
  const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
391
411
  this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
392
412
  this.pendingBootTtlMs = options.pendingBootTtlMs ?? PENDING_BOOT_TTL_MS;
413
+ this.evictionGraceMs = options.evictionGraceMs ?? EVICTION_GRACE_MS;
414
+ this.bootGate = new HostBootGate(() => Date.now(), { ...GATE_DEFAULTS, ...options.bootGate });
393
415
  // Say so rather than only documenting it. Both times this parsing was wrong the symptom was
394
416
  // the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
395
417
  // reading their terminal, not the configuration table.
396
418
  if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
397
419
  logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
398
420
  }
421
+ // Same reason: a cap someone set and mistyped would quietly become the automatic one, which may allow more.
422
+ const capRaw = process.env['TAPFLOW_MAX_AUTOMATED_DEVICES']?.trim();
423
+ const capEnv = capRaw ? Number(capRaw) : NaN;
424
+ this.automatedCapOverride = Number.isInteger(capEnv) && capEnv > 0 ? capEnv : null;
425
+ if (capRaw && this.automatedCapOverride === null) {
426
+ logger.warn(`TAPFLOW_MAX_AUTOMATED_DEVICES="${capRaw}" is not a positive whole number — using half each Mac's memory in GB`);
427
+ }
399
428
  this.sessions = new SessionManager({
400
429
  idleTimeoutMs: options.idleTimeoutMs,
401
430
  // Before the sockets are forgotten, so each one is told why its picture stopped.
@@ -505,8 +534,10 @@ export class RelayServer {
505
534
  this.router.post('/api/v1/runs', handleCreateRun);
506
535
  this.router.get('/api/v1/runs', (req, res) => handleListRuns(req, res, this.runHolders));
507
536
  this.router.get('/api/v1/runs/:id', (req, res, params) => handleGetRun(req, res, params, this.runHolders, videoSummaries));
537
+ this.router.post('/api/v1/runs/:id/device', handleReportDevice);
508
538
  this.router.post('/api/v1/runs/:id/finish', handleFinishRun);
509
539
  this.router.post('/api/v1/runs/:id/flows/:idx', handleReportFlow);
540
+ this.router.post('/api/v1/runs/:id/flows/:idx/start', handleStartFlow);
510
541
  this.router.post('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleUploadFlowScreenshot(req, res, params, this.runScreenshotsDir));
511
542
  this.router.get('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleGetFlowScreenshot(req, res, params, this.runScreenshotsDir));
512
543
  // a flow's video (A4) — uploaded by the runner while the flow runs, kept by the retention policy
@@ -681,6 +712,10 @@ export class RelayServer {
681
712
  // server, and an unref'd 180s timer still outlives the test runner's process.
682
713
  for (const key of this.pendingBoots.keys())
683
714
  this.forgetBoot(key);
715
+ if (this.bootGateTimer) {
716
+ clearTimeout(this.bootGateTimer);
717
+ this.bootGateTimer = null;
718
+ }
684
719
  const closeTunnel = new Promise((resolve, reject) => {
685
720
  // Not listening means `start()` never reached it, or its listen failed; `close()` would reject.
686
721
  if (!this.tunnelServer?.listening)
@@ -886,14 +921,7 @@ export class RelayServer {
886
921
  }
887
922
  return keys;
888
923
  },
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
- },
924
+ heldSessions: (keys) => this.openSocketsOf(keys).flatMap((ws) => this.sessions.getByBrowserSocket(ws).map((s) => s.id)),
897
925
  };
898
926
  isAlive(ws) {
899
927
  const at = this.lastPongAt.get(ws);
@@ -935,6 +963,12 @@ export class RelayServer {
935
963
  * records; the network requester is not one, so the name lost the word rather than the entry
936
964
  * lost the function.
937
965
  */
966
+ /** A session is gone for good: from the manager, from the per-session state, and from the boot gate. */
967
+ removeSession(sessionId) {
968
+ this.sessions.remove(sessionId);
969
+ this.forgetSessionState(sessionId);
970
+ this.gateEvent({ kind: 'session-removed', sessionId });
971
+ }
938
972
  forgetSessionState(sessionId) {
939
973
  this.dropHandlers.delete(sessionId);
940
974
  this.dropHandlers.delete(`${sessionId} watchers`);
@@ -1320,7 +1354,10 @@ export class RelayServer {
1320
1354
  return;
1321
1355
  for (const held of this.sessions.getByBrowserSocket(ws)) {
1322
1356
  this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
1357
+ // Spent, as on a leave: the countdown just started is the one it would have handed back.
1358
+ held.idleResumeOnLeave = false;
1323
1359
  this.tellWatchers(held.id, { type: 'watch:holder-left', sessionId: held.id });
1360
+ this.gateEvent({ kind: 'browser-closed', sessionId: held.id });
1324
1361
  }
1325
1362
  // A run is its runner's socket: the flow runner does not reconnect, so the last socket with its key
1326
1363
  // closing is the end of that run, finished or not. Not done while stopping (above) — a relay going
@@ -1494,7 +1531,8 @@ export class RelayServer {
1494
1531
  case 'agents:list': {
1495
1532
  this.sendTo(ws, {
1496
1533
  type: 'agents:listed',
1497
- sessions: this.sessions.list(this.ownerOf(ws), (holder) => this.isAlive(holder)),
1534
+ sessions: this.sessions.list(this.ownerOf(ws), (holder) => this.isAlive(holder), (group) => this.macAdmission(group)),
1535
+ features: ['device-lease'],
1498
1536
  });
1499
1537
  break;
1500
1538
  }
@@ -1546,15 +1584,22 @@ export class RelayServer {
1546
1584
  // failed would be a guess. The caller keeps its own deadline, as it did before #567.
1547
1585
  if (ended)
1548
1586
  this.settleShutdownRequesters(ended, null);
1549
- this.sessions.remove(msg.sessionId);
1550
- this.forgetSessionState(msg.sessionId);
1587
+ this.removeSession(msg.sessionId);
1551
1588
  }
1552
1589
  break;
1553
1590
  }
1554
1591
  case 'session:leave': {
1555
- if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
1556
- this.sessions.clearBrowser(msg.sessionId);
1592
+ const leaving = this.sessions.get(msg.sessionId);
1593
+ if (this.ownsSession(ws, leaving)) {
1594
+ // An automation client that stopped a person's idle countdown by joining starts it again by leaving;
1595
+ // otherwise a leave keeps the device up, as it always has (a CI job reuses the device it left).
1596
+ const resume = leaving?.idleResumeOnLeave === true;
1597
+ // Cleared after, not before: `clearBrowser` reads it to record whose countdown this is.
1598
+ this.sessions.clearBrowser(msg.sessionId, resume ? () => this.idleShutdown(msg.sessionId) : undefined);
1599
+ if (leaving)
1600
+ leaving.idleResumeOnLeave = false;
1557
1601
  this.forgetSessionState(msg.sessionId);
1602
+ this.gateEvent({ kind: 'browser-closed', sessionId: msg.sessionId });
1558
1603
  // **A leave is the end, not a blip.** The holder said it is done — a flow run finishing, an MCP
1559
1604
  // `disconnect_device` — so the watch ends. `watch:holder-left` is for a socket that dropped, which
1560
1605
  // may come back; sent here it left a watcher of every finished CI run waiting forever.
@@ -1630,6 +1675,7 @@ export class RelayServer {
1630
1675
  if (!session)
1631
1676
  break;
1632
1677
  this.sessions.clearDeviceCache(session.id);
1678
+ this.sessions.setDeviceServiceTier(session.id, undefined);
1633
1679
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1634
1680
  session.browserSocket.send(JSON.stringify(raw));
1635
1681
  }
@@ -1641,18 +1687,32 @@ export class RelayServer {
1641
1687
  // even when nobody is left to forward to. An id-less frame answers no request, so it
1642
1688
  // clears nothing.
1643
1689
  this.settleBoot(msg.sessionId, msg.requestId);
1690
+ this.gateEvent({ kind: 'boot-error', sessionId: msg.sessionId, requestId: msg.requestId });
1644
1691
  const session = this.sessions.get(msg.sessionId);
1692
+ if (session)
1693
+ this.undoLastBoot(session, msg.requestId);
1645
1694
  if (session?.browserSocket?.readyState === WebSocket.OPEN) {
1646
- session.browserSocket.send(JSON.stringify(raw));
1695
+ // `reason` is the relay's word that the Mac is busy, and clients wait out any frame carrying one. An agent's
1696
+ // boot failure is a failure, so a `reason` an agent put on it does not reach them.
1697
+ const { reason: _agentReason, ...fromAgent } = raw;
1698
+ session.browserSocket.send(JSON.stringify(fromAgent));
1647
1699
  }
1648
1700
  break;
1649
1701
  }
1650
1702
  case 'device:shutdown-done': {
1703
+ this.gateEvent({ kind: 'shutdown-done', sessionId: msg.sessionId });
1651
1704
  const session = this.sessions.get(msg.sessionId);
1652
1705
  if (!session)
1653
1706
  break;
1654
1707
  this.sessions.updateDeviceStatus(session.id, 'shutdown');
1655
1708
  this.sessions.setReadySent(session.id, false);
1709
+ this.sessions.setDeviceServiceTier(session.id, undefined);
1710
+ // Unless a boot went out after the shutdown this answers: the shutdown carries no correlator, and a device
1711
+ // the relay shut down to make room can be picked up and started again before the agent reports back.
1712
+ if (session.lastBootPending === undefined)
1713
+ session.lastBoot = undefined;
1714
+ session.lastBootExpired = undefined;
1715
+ session.evictingAt = undefined;
1656
1716
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1657
1717
  session.browserSocket.send(JSON.stringify(raw));
1658
1718
  }
@@ -1665,6 +1725,12 @@ export class RelayServer {
1665
1725
  const session = this.sessions.get(msg.sessionId);
1666
1726
  if (!session)
1667
1727
  break;
1728
+ // An eviction the agent could not carry out: the device may still be up, so it is up again as far as the cap
1729
+ // and the next boot's classification are concerned.
1730
+ if (session.evictingAt !== undefined) {
1731
+ session.evictingAt = undefined;
1732
+ this.sessions.updateDeviceStatus(session.id, 'booted');
1733
+ }
1668
1734
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1669
1735
  session.browserSocket.send(JSON.stringify(raw));
1670
1736
  }
@@ -1682,6 +1748,11 @@ export class RelayServer {
1682
1748
  const session = this.sessions.get(msg.sessionId);
1683
1749
  if (!session)
1684
1750
  break;
1751
+ const facts = readyFacts(raw);
1752
+ if (facts.serviceTier !== undefined)
1753
+ this.sessions.setDeviceServiceTier(session.id, facts.serviceTier);
1754
+ this.settleLastBoot(session, msg.requestId, facts.cold);
1755
+ this.gateEvent({ kind: 'ready', hostKey: hostKeyOf(session), sessionId: session.id, cold: facts.cold });
1685
1756
  this.sessions.updateDeviceStatus(session.id, 'booted');
1686
1757
  this.sessions.setReadySent(session.id, true);
1687
1758
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
@@ -1754,30 +1825,11 @@ export class RelayServer {
1754
1825
  // the diagnosis arrives and is discarded. That defect shipped twice from agent code
1755
1826
  // (`open-url:error`, then `clipboard:error`); here the producer is the relay, where
1756
1827
  // `correlatedRequestsGated` cannot see it because the field is optional.
1757
- const boot = this.dispatchTarget(ws, msg.sessionId);
1758
- if (!boot.ok) {
1759
- this.sendTo(ws, {
1760
- type: 'device:boot-error',
1761
- sessionId: msg.sessionId,
1762
- requestId: msg.requestId,
1763
- message: boot.message,
1764
- });
1765
- break;
1766
- }
1767
- // Tag the boot with whether the viewer is external (public IP) so the agent can pick the
1768
- // downscale tier. The browser already reports secureContext in the payload.
1769
- //
1770
- // **Mutating the parse product, not the frame that arrived**, which is what makes this safe as
1771
- // well as correct: `payload` is a fresh object the parser built, so `raw` is untouched. The
1772
- // presence check the old line carried is gone because the schema requires the payload — and
1773
- // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
1774
- msg.payload['external'] = this.wsExternal.get(ws) ?? false;
1775
- msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
1776
- // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
1777
- // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
1778
- this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
1779
- // The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
1780
- boot.session.agentSocket.send(JSON.stringify(msg));
1828
+ this.handleBoot(ws, msg);
1829
+ break;
1830
+ }
1831
+ case 'device:lease': {
1832
+ this.handleLease(ws, msg);
1781
1833
  break;
1782
1834
  }
1783
1835
  case 'device:shutdown': {
@@ -1979,7 +2031,15 @@ export class RelayServer {
1979
2031
  }
1980
2032
  handleAgentResources(ws, msg) {
1981
2033
  this.sessions.setResources(ws, msg.resources);
1982
- const agentName = this.sessions.getAllByAgentSocket(ws)[0]?.agentName;
2034
+ const first = this.sessions.getAllByAgentSocket(ws)[0];
2035
+ if (first) {
2036
+ this.gateEvent({ kind: 'sample', hostKey: hostKeyOf(first), cpuPercent: msg.resources.cpuPercent });
2037
+ // No reading is not "normal": an agent that cannot read pressure, beside one that can, would clear it every 5 s.
2038
+ const pressure = msg.resources.memoryPressure;
2039
+ if (pressure !== undefined)
2040
+ this.gateEvent({ kind: 'pressure', hostKey: hostKeyOf(first), critical: pressure === 'critical' });
2041
+ }
2042
+ const agentName = first?.agentName;
1983
2043
  if (agentName) {
1984
2044
  const buf = this.resourceBuffers.get(agentName) ?? { cpu: [], mem: [] };
1985
2045
  buf.cpu.push(msg.resources.cpuPercent);
@@ -2073,6 +2133,113 @@ export class RelayServer {
2073
2133
  }
2074
2134
  }
2075
2135
  }
2136
+ /**
2137
+ * The `session:start` resource gate's reading of a session's Mac, before the per-join memory exemption.
2138
+ * One reading for the gate and the device listing, so the dashboard's Mac card shows what a join would meet
2139
+ * rather than a threshold of its own (the card used to recompute 80 % and block on it, which disagreed with the
2140
+ * relay as soon as the relay grew exceptions). Null when the agent has sent no sample, or is away — its last
2141
+ * sample is the dead socket's, and an overloaded Mac is a common reason to restart an agent: fail open.
2142
+ *
2143
+ * While a boot holds this Mac's slot the CPU is that boot's, and the boot gate already decides when the next one
2144
+ * may start. A join is not a boot: refusing it would lock a person out of a device that is already up for the
2145
+ * 30–90 s the boot takes to settle.
2146
+ */
2147
+ resourceLoad(session) {
2148
+ if (session.agentSocket.readyState !== WebSocket.OPEN)
2149
+ return null;
2150
+ const resources = this.sessions.getResources(session.agentSocket);
2151
+ if (!resources)
2152
+ return null;
2153
+ const cpuOver = resources.cpuPercent > RESOURCE_THRESHOLD && !this.bootGate.isBooting(hostKeyOf(session));
2154
+ const memOver = (resources.memUsedMB / resources.memTotalMB) * 100 > RESOURCE_THRESHOLD;
2155
+ return { cpuOver, memOver, over: cpuOver || memOver };
2156
+ }
2157
+ /**
2158
+ * What a person picking one of this agent's devices would meet, summarised for the Mac card. The same reading
2159
+ * `handleSessionStart` applies, with its memory exemption: a person rejoining a device the relay has up
2160
+ * (`readySent`) is not held to memory, so memory alone blocks the Mac only when there is no such device.
2161
+ */
2162
+ macAdmission(group) {
2163
+ const load = this.resourceLoad(group[0]);
2164
+ const memOver = load?.memOver ?? false;
2165
+ return {
2166
+ booting: this.bootGate.isBooting(hostKeyOf(group[0])),
2167
+ overloaded: (load?.cpuOver ?? false) || (memOver && !group.some((s) => s.readySent)),
2168
+ memoryRefuses: memOver ? group.filter((s) => !s.readySent).map((s) => s.deviceId) : [],
2169
+ };
2170
+ }
2171
+ gateEvent(ev) {
2172
+ this.applyGate(this.bootGate.onEvent(ev));
2173
+ }
2174
+ /** Carry out what the boot gate decided, then wake it at its next deadline. */
2175
+ applyGate(effects) {
2176
+ for (const fx of effects) {
2177
+ switch (fx.kind) {
2178
+ case 'deliver':
2179
+ this.forwardBoot(fx.message);
2180
+ break;
2181
+ case 'refuse':
2182
+ this.sendTo(fx.message.ws, {
2183
+ type: 'device:boot-error', sessionId: fx.sessionId, requestId: fx.requestId, reason: fx.reason,
2184
+ message: BOOT_REFUSAL_MESSAGES[fx.reason],
2185
+ });
2186
+ break;
2187
+ case 'waiting':
2188
+ this.sendTo(fx.message.ws, { type: 'device:boot-waiting', sessionId: fx.sessionId, requestId: fx.requestId, until: fx.until, reason: fx.reason });
2189
+ break;
2190
+ case 'withdraw':
2191
+ this.sendTo(fx.message.ws, {
2192
+ type: 'device:boot-error', sessionId: fx.sessionId, requestId: fx.requestId,
2193
+ message: fx.why === 'replaced'
2194
+ ? 'Replaced by a newer boot request for this session'
2195
+ : 'The viewer left while this boot was waiting its turn on this Mac',
2196
+ });
2197
+ break;
2198
+ case 'log':
2199
+ logger.info(`boot gate: ${fx.event} on ${fx.hostKey}${fx.sessionId ? ` (session ${fx.sessionId})` : ''}`);
2200
+ break;
2201
+ default: {
2202
+ const never = fx;
2203
+ throw new Error(`unhandled boot gate effect ${JSON.stringify(never)}`);
2204
+ }
2205
+ }
2206
+ }
2207
+ if (this.bootGateTimer) {
2208
+ clearTimeout(this.bootGateTimer);
2209
+ this.bootGateTimer = null;
2210
+ }
2211
+ const next = this.bootGate.nextDeadline();
2212
+ if (next !== null && !this.stopping) {
2213
+ this.bootGateTimer = setTimeout(() => {
2214
+ this.bootGateTimer = null;
2215
+ this.applyGate(this.bootGate.tick());
2216
+ }, Math.max(0, next - Date.now()));
2217
+ }
2218
+ }
2219
+ /**
2220
+ * Send a boot the gate let through. **Resolved again here** even when the gate answered at once: a held boot can
2221
+ * have outlived its holder's ownership or its agent, and one path for both keeps the two from drifting. A boot
2222
+ * that can no longer go is answered, and the gate is told so the slot it was given is not held for nothing.
2223
+ */
2224
+ forwardBoot({ ws, msg, cost, booterClass }) {
2225
+ const boot = this.dispatchTarget(ws, msg.sessionId);
2226
+ if (!boot.ok) {
2227
+ this.sendTo(ws, { type: 'device:boot-error', sessionId: msg.sessionId, requestId: msg.requestId, message: boot.message });
2228
+ this.gateEvent({ kind: 'boot-error', sessionId: msg.sessionId, requestId: msg.requestId });
2229
+ return;
2230
+ }
2231
+ if (cost === 'cold') {
2232
+ const s = boot.session;
2233
+ // A boot still out keeps the value from before it: `prev` may itself be absent, which is the answer, not a gap.
2234
+ s.lastBootPending = { requestId: msg.requestId, prev: s.lastBootPending ? s.lastBootPending.prev : s.lastBoot };
2235
+ s.lastBoot = { booterClass, at: Date.now() };
2236
+ }
2237
+ // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
2238
+ // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
2239
+ this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
2240
+ // The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
2241
+ boot.session.agentSocket.send(JSON.stringify(msg));
2242
+ }
2076
2243
  bootKey(sessionId, requestId) {
2077
2244
  return `${sessionId}${requestId}`;
2078
2245
  }
@@ -2117,6 +2284,9 @@ export class RelayServer {
2117
2284
  continue;
2118
2285
  this.forgetBoot(key);
2119
2286
  const session = this.sessions.get(pending.sessionId);
2287
+ // The agent that would have answered is gone, so nothing else will settle the record this boot made.
2288
+ if (session)
2289
+ this.undoLastBoot(session, pending.requestId);
2120
2290
  // `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
2121
2291
  // `browserSocket` at all — either way the entry is still gone, so nothing later answers
2122
2292
  // a request nobody holds.
@@ -2243,8 +2413,7 @@ export class RelayServer {
2243
2413
  // better diagnosis — and only a caller that never joined hears from `settleShutdownRequesters`.
2244
2414
  for (const s of agentSessions) {
2245
2415
  this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
2246
- this.sessions.remove(s.id);
2247
- this.forgetSessionState(s.id);
2416
+ this.removeSession(s.id);
2248
2417
  }
2249
2418
  this.sessions.removeResources(ws);
2250
2419
  logger.info(cause === 'replaced'
@@ -2297,6 +2466,7 @@ export class RelayServer {
2297
2466
  if (!device)
2298
2467
  continue;
2299
2468
  this.sessions.rebind(s.id, ws, device, agent);
2469
+ this.gateEvent({ kind: 'rebound', sessionId: s.id });
2300
2470
  rebound.set(s.deviceId, s.id);
2301
2471
  reboundOldSockets.add(old);
2302
2472
  }
@@ -2328,8 +2498,7 @@ export class RelayServer {
2328
2498
  this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
2329
2499
  }
2330
2500
  this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
2331
- this.sessions.remove(s.id);
2332
- this.forgetSessionState(s.id);
2501
+ this.removeSession(s.id);
2333
2502
  }
2334
2503
  }
2335
2504
  // Only devices without a surviving session get a new one. Passing all of them here is what
@@ -2367,6 +2536,179 @@ export class RelayServer {
2367
2536
  // Through `pushLog` too, so `tapflow logs` shows an agent coming back after the line about it leaving.
2368
2537
  this.pushLog(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
2369
2538
  }
2539
+ /** `device:boot`, and the boot a lease sends for the device it chose. */
2540
+ handleBoot(ws, msg) {
2541
+ const boot = this.dispatchTarget(ws, msg.sessionId);
2542
+ if (!boot.ok) {
2543
+ this.sendTo(ws, {
2544
+ type: 'device:boot-error',
2545
+ sessionId: msg.sessionId,
2546
+ requestId: msg.requestId,
2547
+ message: boot.message,
2548
+ });
2549
+ return;
2550
+ }
2551
+ // Tag the boot with whether the viewer is external (public IP) so the agent can pick the
2552
+ // downscale tier. The browser already reports secureContext in the payload.
2553
+ //
2554
+ // **Mutating the parse product, not the frame that arrived**, which is what makes this safe as
2555
+ // well as correct: `payload` is a fresh object the parser built, so `raw` is untouched. The
2556
+ // presence check the old line carried is gone because the schema requires the payload — and
2557
+ // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
2558
+ msg.payload['external'] = this.wsExternal.get(ws) ?? false;
2559
+ msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
2560
+ const cost = classifyBoot(boot.session.deviceStatus, msg.payload, boot.session.deviceServiceTier);
2561
+ const booterClass = isAiClientKind(boot.session.holderKind) ? 'automated' : 'interactive';
2562
+ // The automation cap is decided before the gate and acted on after it: an idle device is shut down to make
2563
+ // room only if this boot actually goes, so a boot the gate refuses costs nobody their device.
2564
+ const capacity = cost === 'cold' && booterClass === 'automated' ? this.planCapacityFor(boot.session) : { ok: true, evict: [] };
2565
+ if (!capacity.ok) {
2566
+ this.sendTo(ws, {
2567
+ type: 'device:boot-error', sessionId: msg.sessionId, requestId: msg.requestId,
2568
+ reason: 'host-at-capacity', message: BOOT_REFUSAL_MESSAGES['host-at-capacity'],
2569
+ });
2570
+ return;
2571
+ }
2572
+ // The boot gate decides whether this goes now, waits (a person), or is refused (automation).
2573
+ const effects = this.bootGate.admit({
2574
+ hostKey: hostKeyOf(boot.session),
2575
+ sessionId: msg.sessionId,
2576
+ requestId: msg.requestId,
2577
+ cost,
2578
+ booterClass,
2579
+ message: { ws, msg, cost, booterClass },
2580
+ });
2581
+ const goes = effects.some((fx) => fx.kind === 'deliver' && fx.requestId === msg.requestId);
2582
+ const critical = effects.some((fx) => fx.kind === 'refuse' && fx.requestId === msg.requestId && fx.reason === 'host-memory-critical');
2583
+ this.applyGate(effects);
2584
+ if (goes)
2585
+ for (const id of capacity.evict)
2586
+ this.evictForCapacity(id);
2587
+ // Idle automation devices are what fills a Mac this way, and while it is critical no boot gets far enough to
2588
+ // shut one down for the cap. So the refusal itself frees one — the oldest idle, never one in use, and not
2589
+ // while an earlier one on this Mac is still shutting down.
2590
+ else if (critical)
2591
+ this.freeOneForMemory(boot.session);
2592
+ }
2593
+ /**
2594
+ * `device:lease`: choose a device, make this client its holder and boot it, in this one call. Nothing here is
2595
+ * awaited, so no other message can take the device between the choice and the join — that is what makes the lease
2596
+ * atomic, and why the two steps are not two messages.
2597
+ *
2598
+ * Every check the join and the boot will make is asked first, without recording anything (`wouldRefuseAutomated`,
2599
+ * `planCapacityFor`, `resourceLoad`), so the device chosen is one both will let through and nothing is undone on a
2600
+ * Mac passed over. Then the ordinary join and the ordinary boot run, so a lease is held to exactly what a client
2601
+ * naming the device would be.
2602
+ */
2603
+ handleLease(ws, msg) {
2604
+ const { requestId } = msg;
2605
+ const refuse = (reason, message) => this.sendTo(ws, {
2606
+ type: 'device:lease-refused', requestId, reason, message, retryable: reason !== 'no-device' && reason !== 'bad-request',
2607
+ });
2608
+ const { payload } = msg;
2609
+ let platform = payload.platform;
2610
+ if (payload.buildId !== undefined) {
2611
+ const build = getDb().prepare('SELECT platform FROM builds WHERE id = ?').get(payload.buildId);
2612
+ if (!build)
2613
+ return refuse('bad-request', `build ${payload.buildId} not found`);
2614
+ if (build.platform === 'ios' || build.platform === 'android')
2615
+ platform = build.platform;
2616
+ }
2617
+ if (!platform)
2618
+ return refuse('bad-request', 'no platform: name one, or a build this relay has');
2619
+ const excluded = new Set(payload.exclude ?? []);
2620
+ const now = Date.now();
2621
+ const ofPlatform = this.sessions.all().filter((s) => s.devicePlatform === platform && !excluded.has(s.id));
2622
+ if (ofPlatform.length === 0)
2623
+ return refuse('no-device', `no ${platform} device on this relay`);
2624
+ // Boot records nobody answered expire where the cap reads them (`capacityDevices`), which a lease meeting only
2625
+ // devices already up would never reach; an expired one would otherwise hide its device from every lease.
2626
+ for (const key of new Set(ofPlatform.map(hostKeyOf)))
2627
+ this.capacityDevices(this.sessions.getAllOnHost(key, hostKeyOf));
2628
+ const tier = payload.serviceTier ? { serviceTier: payload.serviceTier } : {};
2629
+ const candidates = [];
2630
+ const sessionOf = new Map();
2631
+ for (const s of ofPlatform) {
2632
+ if (s.agentSocket.readyState !== WebSocket.OPEN)
2633
+ continue;
2634
+ if (s.owner !== null || this.sessions.watchersOf(s.id).length > 0)
2635
+ continue;
2636
+ if (s.evictingAt !== undefined && now - s.evictingAt < this.evictionGraceMs)
2637
+ continue;
2638
+ if (s.lastBootPending !== undefined)
2639
+ continue;
2640
+ // A person's idle countdown: theirs to come back to. Leasing it would wipe it with the run's reset.
2641
+ if (s.idleTimer !== null && !s.lastHolderWasAi)
2642
+ continue;
2643
+ // Off, or up because automation brought it up through this relay. A simulator opened by hand, or one up before
2644
+ // a relay restart, has no `lastBoot`, and one booting or shutting down reads `unknown`: any of them may be
2645
+ // someone's, so none is ours to reset. Not `readySent`: one of ours whose agent reconnected has lost it, and
2646
+ // the lease's boot binds it again.
2647
+ const ours = s.deviceStatus === 'booted' && s.lastBoot?.booterClass === 'automated';
2648
+ if (s.deviceStatus !== 'shutdown' && !ours)
2649
+ continue;
2650
+ // The boot's own judgement, so the lease cannot disagree with it: one of ours on another tier is restarted onto
2651
+ // the one asked for, which is a cold boot like any other and goes through the same checks.
2652
+ const pool = ours && classifyBoot(s.deviceStatus, tier, s.deviceServiceTier) === 'warm';
2653
+ sessionOf.set(s.id, s);
2654
+ candidates.push({ sessionId: s.id, hostKey: hostKeyOf(s), pool, bootedAt: s.lastBoot?.at ?? 0, refusal: this.leaseRefusal(s, pool) });
2655
+ }
2656
+ if (candidates.length === 0)
2657
+ return refuse('all-held', `every ${platform} device is in use`);
2658
+ const choice = chooseLease(candidates, this.leaseHosts(candidates));
2659
+ if (!choice.ok) {
2660
+ const onCritical = choice.criticalHost !== undefined ? candidates.find((c) => c.hostKey === choice.criticalHost) : undefined;
2661
+ if (onCritical)
2662
+ this.freeOneForMemory(sessionOf.get(onCritical.sessionId));
2663
+ return refuse(choice.reason, choice.reason === 'agent-resources-exhausted' ? 'Agent resources exhausted' : BOOT_REFUSAL_MESSAGES[choice.reason]);
2664
+ }
2665
+ const session = sessionOf.get(choice.sessionId);
2666
+ this.sendTo(ws, {
2667
+ type: 'device:leased', requestId, sessionId: session.id, deviceId: session.deviceId, deviceName: session.deviceName,
2668
+ platform, source: candidates.find((c) => c.sessionId === session.id).pool ? 'pool' : 'boot',
2669
+ });
2670
+ this.handleSessionStart(ws, { type: 'session:start', sessionId: session.id, clientKind: 'flow-runner', ...(payload.stream === false ? { stream: false } : {}) });
2671
+ // The checks above are the join's own, so this holds; if it ever does not, the join has answered the client.
2672
+ if (!this.ownsSession(ws, session))
2673
+ return;
2674
+ this.handleBoot(ws, {
2675
+ type: 'device:boot', sessionId: session.id, requestId,
2676
+ payload: {
2677
+ deviceId: session.deviceId,
2678
+ ...(payload.acceptH264 ? { acceptH264: true } : {}),
2679
+ ...(payload.serviceTier ? { serviceTier: payload.serviceTier } : {}),
2680
+ ...(payload.runReset ? { runReset: payload.runReset } : {}),
2681
+ },
2682
+ });
2683
+ }
2684
+ /** What the join and the boot would refuse an AI client taking this device for, asked without recording anything.
2685
+ * The join's CPU and memory checks, then for a cold boot the automation cap and the boot gate, in their order. */
2686
+ leaseRefusal(s, pool) {
2687
+ const load = this.resourceLoad(s);
2688
+ if (load && (load.cpuOver || load.memOver))
2689
+ return 'agent-resources-exhausted';
2690
+ if (pool)
2691
+ return null;
2692
+ if (!this.planCapacityFor(s).ok)
2693
+ return 'host-at-capacity';
2694
+ return this.bootGate.wouldRefuseAutomated(hostKeyOf(s));
2695
+ }
2696
+ /** Each candidate Mac's room, from its agents' latest report. Not corrected for a boot the report does not show
2697
+ * yet: a boot in flight holds its Mac's slot, so it already keeps every further boot off that Mac, and memory is
2698
+ * only ever compared between Macs that could take one. */
2699
+ leaseHosts(candidates) {
2700
+ const hosts = new Map();
2701
+ for (const key of new Set(candidates.map((c) => c.hostKey))) {
2702
+ const onHost = this.sessions.getAllOnHost(key, hostKeyOf);
2703
+ const reports = [...new Set(onHost.map((s) => s.agentSocket))].map((a) => this.sessions.getResources(a)).filter((r) => r !== undefined);
2704
+ const latest = reports.sort((x, y) => y.reportedAt - x.reportedAt)[0];
2705
+ const automated = this.capacityDevices(onHost).filter((d) => d.automated).length;
2706
+ hosts.set(key, latest
2707
+ ? { freeMB: latest.memTotalMB - latest.memUsedMB, cpuPercent: latest.cpuPercent, automated }
2708
+ : { freeMB: null, cpuPercent: null, automated });
2709
+ }
2710
+ return hosts;
2711
+ }
2370
2712
  /** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
2371
2713
  *
2372
2714
  * That is what makes the address possible rather than aspirational: `msg.sessionId` is narrowed to a
@@ -2412,11 +2754,22 @@ export class RelayServer {
2412
2754
  // Read before the resource gate, and answered before it too. The gate would otherwise read the
2413
2755
  // dead socket's last sample — and an overloaded Mac is a common reason to restart an agent, so
2414
2756
  // the tester would be told the Mac is exhausted at the exact moment it is recovering.
2757
+ //
2758
+ // A person rejoining a device this relay has up is not held to the memory half. The dashboard re-sends
2759
+ // `session:start` on every socket reconnect and the close already cleared the owner, so the tester coming
2760
+ // back reads as a newcomer — and once other devices had pushed memory over the line, a Wi-Fi blink lost
2761
+ // them the device they were using. Booting is what spends the memory, and this one is up.
2762
+ //
2763
+ // `readySent`, not `deviceStatus`: a simulator booted before the agent registered says `booted` without
2764
+ // this relay ever bringing it up (#440), so the viewer's next `device:boot` would be a full boot let in on
2765
+ // a full Mac. An AI client keeps the memory half: its boot warms the UI-tree runner, which does cost
2766
+ // memory on a device already up. The CPU half applies to every join — a join usually starts a stream, and a
2767
+ // stream's encoding is the load #107 is about.
2415
2768
  const agentAway = session.agentSocket.readyState !== WebSocket.OPEN;
2416
- const resources = agentAway ? undefined : this.sessions.getResources(session.agentSocket);
2417
- if (resources) {
2418
- const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
2419
- if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
2769
+ const load = this.resourceLoad(session);
2770
+ if (load) {
2771
+ const memSpent = session.readySent && !isAiClientKind(msg.clientKind);
2772
+ if (load.cpuOver || (load.memOver && !memSpent)) {
2420
2773
  this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Agent resources exhausted', reason: 'agent-resources-exhausted' });
2421
2774
  return;
2422
2775
  }
@@ -2683,6 +3036,100 @@ export class RelayServer {
2683
3036
  payload: { deviceId: session.deviceId },
2684
3037
  });
2685
3038
  }
3039
+ /**
3040
+ * Put `lastBoot` right once the agent says what the boot was. A boot charged cold that found the device running
3041
+ * started nothing, so the record goes back; a boot let through warm that had to start the device after all is
3042
+ * the holder's.
3043
+ */
3044
+ settleLastBoot(session, requestId, cold) {
3045
+ const pending = session.lastBootPending;
3046
+ if (pending && pending.requestId === requestId) {
3047
+ if (cold === false)
3048
+ session.lastBoot = pending.prev;
3049
+ session.lastBootPending = undefined;
3050
+ }
3051
+ else if (cold === true) {
3052
+ const expired = session.lastBootExpired?.requestId === requestId ? session.lastBootExpired : undefined;
3053
+ const booterClass = expired?.booterClass ?? (isAiClientKind(session.holderKind) ? 'automated' : 'interactive');
3054
+ session.lastBoot = { booterClass, at: Date.now() };
3055
+ // An earlier boot of this session brought the device up while a newer one is still out: that newer one
3056
+ // turning out warm must fall back to this, not to before either.
3057
+ if (pending)
3058
+ pending.prev = session.lastBoot;
3059
+ }
3060
+ }
3061
+ /** A boot that did not bring the device up (failed, or stranded by a rebind) says nothing about who started it. */
3062
+ undoLastBoot(session, requestId) {
3063
+ if (session.lastBootPending && session.lastBootPending.requestId === requestId) {
3064
+ session.lastBoot = session.lastBootPending.prev;
3065
+ session.lastBootPending = undefined;
3066
+ }
3067
+ }
3068
+ /** How many automation devices this Mac may keep up: `TAPFLOW_MAX_AUTOMATED_DEVICES`, else from its memory, else
3069
+ * no cap while no agent there has reported any. */
3070
+ automatedCap(onHost) {
3071
+ if (this.automatedCapOverride !== null)
3072
+ return this.automatedCapOverride;
3073
+ let latest;
3074
+ for (const s of onHost) {
3075
+ const r = this.sessions.getResources(s.agentSocket);
3076
+ if (r && (!latest || r.reportedAt > latest.reportedAt))
3077
+ latest = r;
3078
+ }
3079
+ return latest ? defaultCap(latest.memTotalMB) : Number.POSITIVE_INFINITY;
3080
+ }
3081
+ planCapacityFor(requester) {
3082
+ const onHost = this.sessions.getAllOnHost(hostKeyOf(requester), hostKeyOf);
3083
+ return planCapacity(this.capacityDevices(onHost), { sessionId: requester.id, platform: requester.devicePlatform }, this.automatedCap(onHost));
3084
+ }
3085
+ capacityDevices(onHost) {
3086
+ const now = Date.now();
3087
+ // A boot nobody has answered within the correlator's TTL is taken as not having happened. Checked here rather
3088
+ // than on that timer, which a leave or an end clears with the session's other state while the record stays;
3089
+ // a late ready still records the boot again (`settleLastBoot` on `cold: true`).
3090
+ for (const s of onHost) {
3091
+ if (s.lastBootPending && s.lastBoot && now - s.lastBoot.at >= this.pendingBootTtlMs) {
3092
+ s.lastBootExpired = { requestId: s.lastBootPending.requestId, booterClass: s.lastBoot.booterClass };
3093
+ this.undoLastBoot(s, s.lastBootPending.requestId);
3094
+ }
3095
+ }
3096
+ return onHost
3097
+ // A device being shut down to make room is neither up nor available, until the agent says otherwise.
3098
+ .filter((s) => s.evictingAt === undefined || now - s.evictingAt >= this.evictionGraceMs)
3099
+ .map((s) => ({
3100
+ sessionId: s.id,
3101
+ platform: s.devicePlatform,
3102
+ // Up, or being started by automation now: `deviceStatus` reads `shutdown` while a boot runs, so the
3103
+ // boot in flight is known by its pending record instead. An eviction past its grace with no answer from
3104
+ // the agent (filtered above while within it) is taken as not having happened: still up, and idle.
3105
+ automated: s.lastBoot?.booterClass === 'automated' && (s.deviceStatus === 'booted' || s.evictingAt !== undefined || s.lastBootPending !== undefined),
3106
+ booted: (s.deviceStatus === 'booted' || s.evictingAt !== undefined) && s.lastBootPending === undefined,
3107
+ inUse: s.owner !== null || this.sessions.watchersOf(s.id).length > 0,
3108
+ reachable: s.agentSocket.readyState === WebSocket.OPEN,
3109
+ bootedAt: s.lastBoot?.at ?? 0,
3110
+ }));
3111
+ }
3112
+ freeOneForMemory(requester) {
3113
+ const onHost = this.sessions.getAllOnHost(hostKeyOf(requester), hostKeyOf);
3114
+ const now = Date.now();
3115
+ if (onHost.some((s) => s.evictingAt !== undefined && now - s.evictingAt < this.evictionGraceMs))
3116
+ return;
3117
+ const victim = idleToFree(this.capacityDevices(onHost), requester.id);
3118
+ if (victim)
3119
+ this.evictForCapacity(victim);
3120
+ }
3121
+ /** Shut down an idle automation device to make room. Marked first, so nothing replays it as up or picks it again
3122
+ * while the agent works; the mark lifts on `shutdown-done`, `shutdown-error` or after `evictionGraceMs`. */
3123
+ evictForCapacity(sessionId) {
3124
+ const session = this.sessions.get(sessionId);
3125
+ if (session?.agentSocket.readyState !== WebSocket.OPEN)
3126
+ return;
3127
+ session.evictingAt = Date.now();
3128
+ this.sessions.updateDeviceStatus(session.id, 'shutdown');
3129
+ this.sessions.setReadySent(session.id, false);
3130
+ logger.info(`boot gate: shutting down idle automation device ${session.deviceName} on ${hostKeyOf(session)} to make room`);
3131
+ this.sendTo(session.agentSocket, { type: 'device:shutdown', sessionId: session.id, payload: { deviceId: session.deviceId } });
3132
+ }
2686
3133
  /** Where a browser-role command can be dispatched, or what its sender needs to be told instead.
2687
3134
  *
2688
3135
  * Three refusals used to be spread across seven `case` bodies as two conditions each, and the ownership