@tapflowio/relay 0.28.1 → 0.30.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 (204) hide show
  1. package/dist/RelayServer.d.ts +81 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +543 -51
  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/index.d.ts +2 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/lib/config.d.ts +14 -0
  42. package/dist/lib/config.d.ts.map +1 -1
  43. package/dist/lib/config.js +98 -0
  44. package/dist/lib/config.js.map +1 -1
  45. package/dist/lib/launchDefaults.d.ts +25 -0
  46. package/dist/lib/launchDefaults.d.ts.map +1 -0
  47. package/dist/lib/launchDefaults.js +41 -0
  48. package/dist/lib/launchDefaults.js.map +1 -0
  49. package/dist/migrations/017_flow_run_skipped.sql +34 -0
  50. package/dist/migrations/018_flow_run_service_tier.sql +6 -0
  51. package/dist/migrations/019_flow_run_device_reset.sql +7 -0
  52. package/dist/server.js +2 -2
  53. package/dist/server.js.map +1 -1
  54. package/package.json +4 -4
  55. package/public/assets/{AISessionWatch-C2h4NZ_I.js → AISessionWatch-DxsyhjAy.js} +7 -7
  56. package/public/assets/AISessionWatch-DxsyhjAy.js.br +0 -0
  57. package/public/assets/AISessionWatch-DxsyhjAy.js.gz +0 -0
  58. package/public/assets/{AppCenter-S1y-8_7l.js → AppCenter-CZMTgfBh.js} +6 -6
  59. package/public/assets/AppCenter-CZMTgfBh.js.br +0 -0
  60. package/public/assets/{AppCenter-S1y-8_7l.js.gz → AppCenter-CZMTgfBh.js.gz} +0 -0
  61. package/public/assets/{Default-C-l1O-g9.js → Default-DCk7wCIg.js} +1 -1
  62. package/public/assets/Default-DCk7wCIg.js.br +0 -0
  63. package/public/assets/Default-DCk7wCIg.js.gz +0 -0
  64. package/public/assets/{Invite-BCndVxaO.js → Invite-BNgu8qb9.js} +1 -1
  65. package/public/assets/Invite-BNgu8qb9.js.br +0 -0
  66. package/public/assets/Invite-BNgu8qb9.js.gz +0 -0
  67. package/public/assets/{MacResources-41cFDgE1.js → MacResources-B26lSN5B.js} +1 -1
  68. package/public/assets/MacResources-B26lSN5B.js.br +0 -0
  69. package/public/assets/MacResources-B26lSN5B.js.gz +0 -0
  70. package/public/assets/QASession-Rin1Ol-2.js +81 -0
  71. package/public/assets/QASession-Rin1Ol-2.js.br +0 -0
  72. package/public/assets/QASession-Rin1Ol-2.js.gz +0 -0
  73. package/public/assets/{ResetPassword-8rj7ASt1.js → ResetPassword-CQsALVBn.js} +1 -1
  74. package/public/assets/ResetPassword-CQsALVBn.js.br +0 -0
  75. package/public/assets/ResetPassword-CQsALVBn.js.gz +0 -0
  76. package/public/assets/RunDetail-D0JKuXAS.js +16 -0
  77. package/public/assets/RunDetail-D0JKuXAS.js.br +0 -0
  78. package/public/assets/RunDetail-D0JKuXAS.js.gz +0 -0
  79. package/public/assets/Runs-DSHpDs0m.js +6 -0
  80. package/public/assets/Runs-DSHpDs0m.js.br +0 -0
  81. package/public/assets/Runs-DSHpDs0m.js.gz +0 -0
  82. package/public/assets/{Setup-DZrhA_7b.js → Setup-4-HC1rwb.js} +1 -1
  83. package/public/assets/Setup-4-HC1rwb.js.br +0 -0
  84. package/public/assets/Setup-4-HC1rwb.js.gz +0 -0
  85. package/public/assets/{Team-CJThCjd3.js → Team-CgJM95TR.js} +2 -2
  86. package/public/assets/Team-CgJM95TR.js.br +0 -0
  87. package/public/assets/Team-CgJM95TR.js.gz +0 -0
  88. package/public/assets/{Tokens-DGURtHWi.js → Tokens-D2wbiDVG.js} +1 -1
  89. package/public/assets/Tokens-D2wbiDVG.js.br +0 -0
  90. package/public/assets/Tokens-D2wbiDVG.js.gz +0 -0
  91. package/public/assets/{aiSession-BW09Jhqz.js → aiSession-DE80E1QU.js} +6 -6
  92. package/public/assets/aiSession-DE80E1QU.js.br +0 -0
  93. package/public/assets/aiSession-DE80E1QU.js.gz +0 -0
  94. package/public/assets/{alert-dialog-t5vlOabq.js → alert-dialog-nxQTqXgk.js} +1 -1
  95. package/public/assets/alert-dialog-nxQTqXgk.js.br +0 -0
  96. package/public/assets/alert-dialog-nxQTqXgk.js.gz +0 -0
  97. package/public/assets/{badge-DrDCTAQQ.js → badge-DM1m4CLq.js} +1 -1
  98. package/public/assets/badge-DM1m4CLq.js.br +0 -0
  99. package/public/assets/badge-DM1m4CLq.js.gz +0 -0
  100. package/public/assets/{dialog-BOFZkCES.js → dialog-CTc84wr4.js} +1 -1
  101. package/public/assets/dialog-CTc84wr4.js.br +0 -0
  102. package/public/assets/dialog-CTc84wr4.js.gz +0 -0
  103. package/public/assets/{field-hint-B9fPUgi7.js → field-hint-xptj_gfn.js} +1 -1
  104. package/public/assets/field-hint-xptj_gfn.js.br +0 -0
  105. package/public/assets/field-hint-xptj_gfn.js.gz +0 -0
  106. package/public/assets/{index-Bixhy5MB.js → index-2zL1a5_0.js} +39 -39
  107. package/public/assets/index-2zL1a5_0.js.br +0 -0
  108. package/public/assets/index-2zL1a5_0.js.gz +0 -0
  109. package/public/assets/{list-view-BpOY1e-d.js → list-view-BzI8tLvX.js} +1 -1
  110. package/public/assets/list-view-BzI8tLvX.js.br +0 -0
  111. package/public/assets/list-view-BzI8tLvX.js.gz +0 -0
  112. package/public/assets/{pencil-ZlrAsWuU.js → pencil-CEWs8Of9.js} +2 -2
  113. package/public/assets/pencil-CEWs8Of9.js.br +0 -0
  114. package/public/assets/pencil-CEWs8Of9.js.gz +0 -0
  115. package/public/assets/{play-4CsGZdQU.js → play-Dab2J3Tw.js} +2 -2
  116. package/public/assets/play-Dab2J3Tw.js.br +0 -0
  117. package/public/assets/play-Dab2J3Tw.js.gz +0 -0
  118. package/public/assets/{runs-CInQb6v1.js → runs-CFQ6M4rx.js} +1 -1
  119. package/public/assets/runs-CFQ6M4rx.js.br +0 -0
  120. package/public/assets/runs-CFQ6M4rx.js.gz +0 -0
  121. package/public/assets/{search-input-G_QpBvPM.js → search-input-BK7cFT1m.js} +2 -2
  122. package/public/assets/search-input-BK7cFT1m.js.br +0 -0
  123. package/public/assets/search-input-BK7cFT1m.js.gz +0 -0
  124. package/public/assets/{select-DDzv2YfY.js → select-BH7E2Q0r.js} +3 -3
  125. package/public/assets/select-BH7E2Q0r.js.br +0 -0
  126. package/public/assets/select-BH7E2Q0r.js.gz +0 -0
  127. package/public/assets/{slider-DE7lQdK4.js → slider-B4sPZi8m.js} +1 -1
  128. package/public/assets/slider-B4sPZi8m.js.br +0 -0
  129. package/public/assets/slider-B4sPZi8m.js.gz +0 -0
  130. package/public/assets/{tabs-Co1N3x_X.js → tabs-CYoB-S2t.js} +1 -1
  131. package/public/assets/tabs-CYoB-S2t.js.br +0 -0
  132. package/public/assets/tabs-CYoB-S2t.js.gz +0 -0
  133. package/public/assets/{tech-label-BMvuJmUf.js → tech-label-fz5PvoAL.js} +1 -1
  134. package/public/assets/tech-label-fz5PvoAL.js.br +3 -0
  135. package/public/assets/tech-label-fz5PvoAL.js.gz +0 -0
  136. package/public/assets/{trash-Cwj2dbgB.js → trash-CZ-V9h2G.js} +3 -3
  137. package/public/assets/trash-CZ-V9h2G.js.br +0 -0
  138. package/public/assets/trash-CZ-V9h2G.js.gz +0 -0
  139. package/public/assets/{video-swsaHrfi.js → video-CilstoqV.js} +2 -2
  140. package/public/assets/video-CilstoqV.js.br +0 -0
  141. package/public/assets/video-CilstoqV.js.gz +0 -0
  142. package/public/index.html +1 -1
  143. package/public/index.html.br +0 -0
  144. package/public/index.html.gz +0 -0
  145. package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
  146. package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
  147. package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
  148. package/public/assets/Default-C-l1O-g9.js.br +0 -0
  149. package/public/assets/Default-C-l1O-g9.js.gz +0 -0
  150. package/public/assets/Invite-BCndVxaO.js.br +0 -0
  151. package/public/assets/Invite-BCndVxaO.js.gz +0 -0
  152. package/public/assets/MacResources-41cFDgE1.js.br +0 -0
  153. package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
  154. package/public/assets/QASession-B67RsIPm.js +0 -81
  155. package/public/assets/QASession-B67RsIPm.js.br +0 -0
  156. package/public/assets/QASession-B67RsIPm.js.gz +0 -0
  157. package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
  158. package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
  159. package/public/assets/RunDetail-BIgq9gJ0.js +0 -16
  160. package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
  161. package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
  162. package/public/assets/Runs-DpK5aoBr.js +0 -6
  163. package/public/assets/Runs-DpK5aoBr.js.br +0 -0
  164. package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
  165. package/public/assets/Setup-DZrhA_7b.js.br +0 -0
  166. package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
  167. package/public/assets/Team-CJThCjd3.js.br +0 -0
  168. package/public/assets/Team-CJThCjd3.js.gz +0 -0
  169. package/public/assets/Tokens-DGURtHWi.js.br +0 -0
  170. package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
  171. package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
  172. package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
  173. package/public/assets/alert-dialog-t5vlOabq.js.br +0 -0
  174. package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
  175. package/public/assets/badge-DrDCTAQQ.js.br +0 -0
  176. package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
  177. package/public/assets/dialog-BOFZkCES.js.br +0 -0
  178. package/public/assets/dialog-BOFZkCES.js.gz +0 -0
  179. package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
  180. package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
  181. package/public/assets/index-Bixhy5MB.js.br +0 -0
  182. package/public/assets/index-Bixhy5MB.js.gz +0 -0
  183. package/public/assets/list-view-BpOY1e-d.js.br +0 -0
  184. package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
  185. package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
  186. package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
  187. package/public/assets/play-4CsGZdQU.js.br +0 -0
  188. package/public/assets/play-4CsGZdQU.js.gz +0 -0
  189. package/public/assets/runs-CInQb6v1.js.br +0 -0
  190. package/public/assets/runs-CInQb6v1.js.gz +0 -0
  191. package/public/assets/search-input-G_QpBvPM.js.br +0 -0
  192. package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
  193. package/public/assets/select-DDzv2YfY.js.br +0 -0
  194. package/public/assets/select-DDzv2YfY.js.gz +0 -0
  195. package/public/assets/slider-DE7lQdK4.js.br +0 -0
  196. package/public/assets/slider-DE7lQdK4.js.gz +0 -0
  197. package/public/assets/tabs-Co1N3x_X.js.br +0 -0
  198. package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
  199. package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
  200. package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
  201. package/public/assets/trash-Cwj2dbgB.js.br +0 -0
  202. package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
  203. package/public/assets/video-swsaHrfi.js.br +0 -0
  204. package/public/assets/video-swsaHrfi.js.gz +0 -0
@@ -7,7 +7,13 @@ 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';
16
+ import { compactLaunchOptions, launchOptionsProblem } from '@tapflowio/protocol/launch';
11
17
  import { Router, json } from './router.js';
12
18
  import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
13
19
  import { AGENT_SCOPE, VIEW_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
@@ -18,6 +24,7 @@ import { resolveCorsHeaders } from './lib/cors.js';
18
24
  import { isCsrfBlocked } from './lib/csrf.js';
19
25
  import { pickLanAddress, runningInContainer } from './lib/lanAddress.js';
20
26
  import { config, getJwtSecret } from './lib/config.js';
27
+ import { mergeLaunch, unmatchedLaunchKeys } from './lib/launchDefaults.js';
21
28
  import { forTeammates, resolveAgentRelayUrl, resolvePublicBaseUrl } from './lib/publicUrl.js';
22
29
  import { createTrailingRequester, systemTimerScheduler } from './lib/trailingRequester.js';
23
30
  import { getDb } from './db.js';
@@ -91,7 +98,7 @@ import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
91
98
  import { handleUpdateProfile } from './api/profile.js';
92
99
  import { handleUploadRecording, handleListRecordings, handleDownloadRecording, purgeExpiredRecordings } from './api/recordings.js';
93
100
  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';
101
+ import { abandonRunsOf, handleCreateRun, handleFinishRun, handleReportDevice, handleStartFlow, handleGetFlowScreenshot, handleGetRun, handleListRuns, handleReportFlow, handleUploadFlowScreenshot, purgeExpiredRuns, runScreenshotsDirFor } from './api/runs.js';
95
102
  import { handleListAgents, handleGetAgentResources } from './api/agents.js';
96
103
  const MIME_TYPES = {
97
104
  '.html': 'text/html',
@@ -109,6 +116,15 @@ const MIME_TYPES = {
109
116
  };
110
117
  const _parsedThreshold = parseInt(process.env['TAPFLOW_RESOURCE_THRESHOLD_PERCENT'] ?? '80', 10);
111
118
  const RESOURCE_THRESHOLD = Number.isFinite(_parsedThreshold) ? _parsedThreshold : 80;
119
+ /** How long a device the relay shut down to make room stays out of the automation count without word from its
120
+ * agent. A shutdown takes seconds; past this, assume it did not happen and count the device again. */
121
+ const EVICTION_GRACE_MS = 60_000;
122
+ /** The prose on a refused boot. Clients act on `reason`; this is for the person reading a log. */
123
+ const BOOT_REFUSAL_MESSAGES = {
124
+ 'host-booting': 'Another device is still booting on this Mac. Try again once it has settled.',
125
+ 'host-memory-critical': 'This Mac is critically short of memory. Try again once it has recovered.',
126
+ 'host-at-capacity': 'This Mac already runs as many automation devices as it allows, and all of them are in use.',
127
+ };
112
128
  // Messages that only agents are allowed to send. Authenticated browser sockets
113
129
  // that send any of these are disconnected immediately.
114
130
  // Terminal input messages the MCP client awaits an ack for — if the agent is offline
@@ -322,12 +338,20 @@ export class RelayServer {
322
338
  /** How long a tracked boot lives without an answer. An option so tests can shrink the
323
339
  * 180s horizon; production always takes the default. */
324
340
  pendingBootTtlMs;
341
+ /** One cold boot at a time per Mac (`admission/HostBootGate.ts`). */
342
+ bootGate;
343
+ /** `TAPFLOW_MAX_AUTOMATED_DEVICES`, when it is usable; otherwise each Mac's cap comes from its memory. */
344
+ automatedCapOverride;
345
+ evictionGraceMs;
346
+ bootGateTimer = null;
325
347
  corsAllowed;
326
348
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
327
349
  warnedProxyMisconfig = false;
328
350
  warnedTrustedProxyWithoutXff = false;
329
351
  /** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
330
352
  warnedLegacyInstaller = new Set();
353
+ /** `<bundle id>:<what was left out>` already warned about. Bounded by the config's keys, two reasons each. */
354
+ warnedDroppedDefaults = new Set();
331
355
  buildTickets = new BuildTicketStore();
332
356
  pendingScreenshots = new Map();
333
357
  uiTreeTimeoutMs;
@@ -390,12 +414,21 @@ export class RelayServer {
390
414
  const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
391
415
  this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
392
416
  this.pendingBootTtlMs = options.pendingBootTtlMs ?? PENDING_BOOT_TTL_MS;
417
+ this.evictionGraceMs = options.evictionGraceMs ?? EVICTION_GRACE_MS;
418
+ this.bootGate = new HostBootGate(() => Date.now(), { ...GATE_DEFAULTS, ...options.bootGate });
393
419
  // Say so rather than only documenting it. Both times this parsing was wrong the symptom was
394
420
  // the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
395
421
  // reading their terminal, not the configuration table.
396
422
  if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
397
423
  logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
398
424
  }
425
+ // Same reason: a cap someone set and mistyped would quietly become the automatic one, which may allow more.
426
+ const capRaw = process.env['TAPFLOW_MAX_AUTOMATED_DEVICES']?.trim();
427
+ const capEnv = capRaw ? Number(capRaw) : NaN;
428
+ this.automatedCapOverride = Number.isInteger(capEnv) && capEnv > 0 ? capEnv : null;
429
+ if (capRaw && this.automatedCapOverride === null) {
430
+ logger.warn(`TAPFLOW_MAX_AUTOMATED_DEVICES="${capRaw}" is not a positive whole number — using half each Mac's memory in GB`);
431
+ }
399
432
  this.sessions = new SessionManager({
400
433
  idleTimeoutMs: options.idleTimeoutMs,
401
434
  // Before the sockets are forgotten, so each one is told why its picture stopped.
@@ -505,8 +538,10 @@ export class RelayServer {
505
538
  this.router.post('/api/v1/runs', handleCreateRun);
506
539
  this.router.get('/api/v1/runs', (req, res) => handleListRuns(req, res, this.runHolders));
507
540
  this.router.get('/api/v1/runs/:id', (req, res, params) => handleGetRun(req, res, params, this.runHolders, videoSummaries));
541
+ this.router.post('/api/v1/runs/:id/device', handleReportDevice);
508
542
  this.router.post('/api/v1/runs/:id/finish', handleFinishRun);
509
543
  this.router.post('/api/v1/runs/:id/flows/:idx', handleReportFlow);
544
+ this.router.post('/api/v1/runs/:id/flows/:idx/start', handleStartFlow);
510
545
  this.router.post('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleUploadFlowScreenshot(req, res, params, this.runScreenshotsDir));
511
546
  this.router.get('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleGetFlowScreenshot(req, res, params, this.runScreenshotsDir));
512
547
  // a flow's video (A4) — uploaded by the runner while the flow runs, kept by the retention policy
@@ -566,6 +601,7 @@ export class RelayServer {
566
601
  getJwtSecret();
567
602
  this.warnOrphanedAgentTokens();
568
603
  this.warnDuplicateEmails();
604
+ this.warnUnmatchedLaunchDefaults();
569
605
  purgeExpiredRecordings(this.recordingsDir);
570
606
  this.purgeRecordingsTimer = setInterval(() => purgeExpiredRecordings(this.recordingsDir), 24 * 60 * 60 * 1000);
571
607
  this.purgeRecordingsTimer.unref();
@@ -681,6 +717,10 @@ export class RelayServer {
681
717
  // server, and an unref'd 180s timer still outlives the test runner's process.
682
718
  for (const key of this.pendingBoots.keys())
683
719
  this.forgetBoot(key);
720
+ if (this.bootGateTimer) {
721
+ clearTimeout(this.bootGateTimer);
722
+ this.bootGateTimer = null;
723
+ }
684
724
  const closeTunnel = new Promise((resolve, reject) => {
685
725
  // Not listening means `start()` never reached it, or its listen failed; `close()` would reject.
686
726
  if (!this.tunnelServer?.listening)
@@ -702,6 +742,25 @@ export class RelayServer {
702
742
  tunnelAddress() {
703
743
  return this.tunnelServer?.address() ?? null;
704
744
  }
745
+ // Once per app and reason: an app shared by iOS and Android with an env default would otherwise warn on every
746
+ // Android launch.
747
+ warnDroppedDefaults(bundleId, dropped) {
748
+ const key = `${bundleId}:${dropped}`;
749
+ if (this.warnedDroppedDefaults.has(key))
750
+ return;
751
+ this.warnedDroppedDefaults.add(key);
752
+ logger.warn(dropped === 'all'
753
+ ? `${bundleId}: launched without its launch defaults — the device's tapflow agent is too old to pass them`
754
+ : `${bundleId}: launched without its env defaults — the device's tapflow agent cannot set an app's environment (Android, or an older agent)`);
755
+ }
756
+ warnUnmatchedLaunchDefaults() {
757
+ const keys = this.options.launchDefaults?.keys();
758
+ if (!keys)
759
+ return;
760
+ const uploaded = getDb().prepare('SELECT DISTINCT bundle_id FROM builds WHERE bundle_id IS NOT NULL').all().map((r) => r.bundle_id);
761
+ for (const warning of unmatchedLaunchKeys(keys, uploaded))
762
+ logger.warn(warning);
763
+ }
705
764
  /**
706
765
  * Migration 014 normalizes stored addresses but leaves alone any two that normalize to the same one —
707
766
  * rewriting them would break `UNIQUE(email)` and the migration with it. Each still signs in by its
@@ -886,14 +945,7 @@ export class RelayServer {
886
945
  }
887
946
  return keys;
888
947
  },
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
- },
948
+ heldSessions: (keys) => this.openSocketsOf(keys).flatMap((ws) => this.sessions.getByBrowserSocket(ws).map((s) => s.id)),
897
949
  };
898
950
  isAlive(ws) {
899
951
  const at = this.lastPongAt.get(ws);
@@ -935,6 +987,12 @@ export class RelayServer {
935
987
  * records; the network requester is not one, so the name lost the word rather than the entry
936
988
  * lost the function.
937
989
  */
990
+ /** A session is gone for good: from the manager, from the per-session state, and from the boot gate. */
991
+ removeSession(sessionId) {
992
+ this.sessions.remove(sessionId);
993
+ this.forgetSessionState(sessionId);
994
+ this.gateEvent({ kind: 'session-removed', sessionId });
995
+ }
938
996
  forgetSessionState(sessionId) {
939
997
  this.dropHandlers.delete(sessionId);
940
998
  this.dropHandlers.delete(`${sessionId} watchers`);
@@ -1320,7 +1378,10 @@ export class RelayServer {
1320
1378
  return;
1321
1379
  for (const held of this.sessions.getByBrowserSocket(ws)) {
1322
1380
  this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
1381
+ // Spent, as on a leave: the countdown just started is the one it would have handed back.
1382
+ held.idleResumeOnLeave = false;
1323
1383
  this.tellWatchers(held.id, { type: 'watch:holder-left', sessionId: held.id });
1384
+ this.gateEvent({ kind: 'browser-closed', sessionId: held.id });
1324
1385
  }
1325
1386
  // A run is its runner's socket: the flow runner does not reconnect, so the last socket with its key
1326
1387
  // closing is the end of that run, finished or not. Not done while stopping (above) — a relay going
@@ -1494,7 +1555,8 @@ export class RelayServer {
1494
1555
  case 'agents:list': {
1495
1556
  this.sendTo(ws, {
1496
1557
  type: 'agents:listed',
1497
- sessions: this.sessions.list(this.ownerOf(ws), (holder) => this.isAlive(holder)),
1558
+ sessions: this.sessions.list(this.ownerOf(ws), (holder) => this.isAlive(holder), (group) => this.macAdmission(group)),
1559
+ features: ['device-lease', 'launch-options'],
1498
1560
  });
1499
1561
  break;
1500
1562
  }
@@ -1546,15 +1608,22 @@ export class RelayServer {
1546
1608
  // failed would be a guess. The caller keeps its own deadline, as it did before #567.
1547
1609
  if (ended)
1548
1610
  this.settleShutdownRequesters(ended, null);
1549
- this.sessions.remove(msg.sessionId);
1550
- this.forgetSessionState(msg.sessionId);
1611
+ this.removeSession(msg.sessionId);
1551
1612
  }
1552
1613
  break;
1553
1614
  }
1554
1615
  case 'session:leave': {
1555
- if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
1556
- this.sessions.clearBrowser(msg.sessionId);
1616
+ const leaving = this.sessions.get(msg.sessionId);
1617
+ if (this.ownsSession(ws, leaving)) {
1618
+ // An automation client that stopped a person's idle countdown by joining starts it again by leaving;
1619
+ // otherwise a leave keeps the device up, as it always has (a CI job reuses the device it left).
1620
+ const resume = leaving?.idleResumeOnLeave === true;
1621
+ // Cleared after, not before: `clearBrowser` reads it to record whose countdown this is.
1622
+ this.sessions.clearBrowser(msg.sessionId, resume ? () => this.idleShutdown(msg.sessionId) : undefined);
1623
+ if (leaving)
1624
+ leaving.idleResumeOnLeave = false;
1557
1625
  this.forgetSessionState(msg.sessionId);
1626
+ this.gateEvent({ kind: 'browser-closed', sessionId: msg.sessionId });
1558
1627
  // **A leave is the end, not a blip.** The holder said it is done — a flow run finishing, an MCP
1559
1628
  // `disconnect_device` — so the watch ends. `watch:holder-left` is for a socket that dropped, which
1560
1629
  // may come back; sent here it left a watcher of every finished CI run waiting forever.
@@ -1630,6 +1699,7 @@ export class RelayServer {
1630
1699
  if (!session)
1631
1700
  break;
1632
1701
  this.sessions.clearDeviceCache(session.id);
1702
+ this.sessions.setDeviceServiceTier(session.id, undefined);
1633
1703
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1634
1704
  session.browserSocket.send(JSON.stringify(raw));
1635
1705
  }
@@ -1641,18 +1711,32 @@ export class RelayServer {
1641
1711
  // even when nobody is left to forward to. An id-less frame answers no request, so it
1642
1712
  // clears nothing.
1643
1713
  this.settleBoot(msg.sessionId, msg.requestId);
1714
+ this.gateEvent({ kind: 'boot-error', sessionId: msg.sessionId, requestId: msg.requestId });
1644
1715
  const session = this.sessions.get(msg.sessionId);
1716
+ if (session)
1717
+ this.undoLastBoot(session, msg.requestId);
1645
1718
  if (session?.browserSocket?.readyState === WebSocket.OPEN) {
1646
- session.browserSocket.send(JSON.stringify(raw));
1719
+ // `reason` is the relay's word that the Mac is busy, and clients wait out any frame carrying one. An agent's
1720
+ // boot failure is a failure, so a `reason` an agent put on it does not reach them.
1721
+ const { reason: _agentReason, ...fromAgent } = raw;
1722
+ session.browserSocket.send(JSON.stringify(fromAgent));
1647
1723
  }
1648
1724
  break;
1649
1725
  }
1650
1726
  case 'device:shutdown-done': {
1727
+ this.gateEvent({ kind: 'shutdown-done', sessionId: msg.sessionId });
1651
1728
  const session = this.sessions.get(msg.sessionId);
1652
1729
  if (!session)
1653
1730
  break;
1654
1731
  this.sessions.updateDeviceStatus(session.id, 'shutdown');
1655
1732
  this.sessions.setReadySent(session.id, false);
1733
+ this.sessions.setDeviceServiceTier(session.id, undefined);
1734
+ // Unless a boot went out after the shutdown this answers: the shutdown carries no correlator, and a device
1735
+ // the relay shut down to make room can be picked up and started again before the agent reports back.
1736
+ if (session.lastBootPending === undefined)
1737
+ session.lastBoot = undefined;
1738
+ session.lastBootExpired = undefined;
1739
+ session.evictingAt = undefined;
1656
1740
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1657
1741
  session.browserSocket.send(JSON.stringify(raw));
1658
1742
  }
@@ -1665,6 +1749,12 @@ export class RelayServer {
1665
1749
  const session = this.sessions.get(msg.sessionId);
1666
1750
  if (!session)
1667
1751
  break;
1752
+ // An eviction the agent could not carry out: the device may still be up, so it is up again as far as the cap
1753
+ // and the next boot's classification are concerned.
1754
+ if (session.evictingAt !== undefined) {
1755
+ session.evictingAt = undefined;
1756
+ this.sessions.updateDeviceStatus(session.id, 'booted');
1757
+ }
1668
1758
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1669
1759
  session.browserSocket.send(JSON.stringify(raw));
1670
1760
  }
@@ -1682,6 +1772,11 @@ export class RelayServer {
1682
1772
  const session = this.sessions.get(msg.sessionId);
1683
1773
  if (!session)
1684
1774
  break;
1775
+ const facts = readyFacts(raw);
1776
+ if (facts.serviceTier !== undefined)
1777
+ this.sessions.setDeviceServiceTier(session.id, facts.serviceTier);
1778
+ this.settleLastBoot(session, msg.requestId, facts.cold);
1779
+ this.gateEvent({ kind: 'ready', hostKey: hostKeyOf(session), sessionId: session.id, cold: facts.cold });
1685
1780
  this.sessions.updateDeviceStatus(session.id, 'booted');
1686
1781
  this.sessions.setReadySent(session.id, true);
1687
1782
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
@@ -1754,30 +1849,11 @@ export class RelayServer {
1754
1849
  // the diagnosis arrives and is discarded. That defect shipped twice from agent code
1755
1850
  // (`open-url:error`, then `clipboard:error`); here the producer is the relay, where
1756
1851
  // `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));
1852
+ this.handleBoot(ws, msg);
1853
+ break;
1854
+ }
1855
+ case 'device:lease': {
1856
+ this.handleLease(ws, msg);
1781
1857
  break;
1782
1858
  }
1783
1859
  case 'device:shutdown': {
@@ -1979,7 +2055,15 @@ export class RelayServer {
1979
2055
  }
1980
2056
  handleAgentResources(ws, msg) {
1981
2057
  this.sessions.setResources(ws, msg.resources);
1982
- const agentName = this.sessions.getAllByAgentSocket(ws)[0]?.agentName;
2058
+ const first = this.sessions.getAllByAgentSocket(ws)[0];
2059
+ if (first) {
2060
+ this.gateEvent({ kind: 'sample', hostKey: hostKeyOf(first), cpuPercent: msg.resources.cpuPercent });
2061
+ // No reading is not "normal": an agent that cannot read pressure, beside one that can, would clear it every 5 s.
2062
+ const pressure = msg.resources.memoryPressure;
2063
+ if (pressure !== undefined)
2064
+ this.gateEvent({ kind: 'pressure', hostKey: hostKeyOf(first), critical: pressure === 'critical' });
2065
+ }
2066
+ const agentName = first?.agentName;
1983
2067
  if (agentName) {
1984
2068
  const buf = this.resourceBuffers.get(agentName) ?? { cpu: [], mem: [] };
1985
2069
  buf.cpu.push(msg.resources.cpuPercent);
@@ -2073,6 +2157,113 @@ export class RelayServer {
2073
2157
  }
2074
2158
  }
2075
2159
  }
2160
+ /**
2161
+ * The `session:start` resource gate's reading of a session's Mac, before the per-join memory exemption.
2162
+ * One reading for the gate and the device listing, so the dashboard's Mac card shows what a join would meet
2163
+ * rather than a threshold of its own (the card used to recompute 80 % and block on it, which disagreed with the
2164
+ * relay as soon as the relay grew exceptions). Null when the agent has sent no sample, or is away — its last
2165
+ * sample is the dead socket's, and an overloaded Mac is a common reason to restart an agent: fail open.
2166
+ *
2167
+ * While a boot holds this Mac's slot the CPU is that boot's, and the boot gate already decides when the next one
2168
+ * may start. A join is not a boot: refusing it would lock a person out of a device that is already up for the
2169
+ * 30–90 s the boot takes to settle.
2170
+ */
2171
+ resourceLoad(session) {
2172
+ if (session.agentSocket.readyState !== WebSocket.OPEN)
2173
+ return null;
2174
+ const resources = this.sessions.getResources(session.agentSocket);
2175
+ if (!resources)
2176
+ return null;
2177
+ const cpuOver = resources.cpuPercent > RESOURCE_THRESHOLD && !this.bootGate.isBooting(hostKeyOf(session));
2178
+ const memOver = (resources.memUsedMB / resources.memTotalMB) * 100 > RESOURCE_THRESHOLD;
2179
+ return { cpuOver, memOver, over: cpuOver || memOver };
2180
+ }
2181
+ /**
2182
+ * What a person picking one of this agent's devices would meet, summarised for the Mac card. The same reading
2183
+ * `handleSessionStart` applies, with its memory exemption: a person rejoining a device the relay has up
2184
+ * (`readySent`) is not held to memory, so memory alone blocks the Mac only when there is no such device.
2185
+ */
2186
+ macAdmission(group) {
2187
+ const load = this.resourceLoad(group[0]);
2188
+ const memOver = load?.memOver ?? false;
2189
+ return {
2190
+ booting: this.bootGate.isBooting(hostKeyOf(group[0])),
2191
+ overloaded: (load?.cpuOver ?? false) || (memOver && !group.some((s) => s.readySent)),
2192
+ memoryRefuses: memOver ? group.filter((s) => !s.readySent).map((s) => s.deviceId) : [],
2193
+ };
2194
+ }
2195
+ gateEvent(ev) {
2196
+ this.applyGate(this.bootGate.onEvent(ev));
2197
+ }
2198
+ /** Carry out what the boot gate decided, then wake it at its next deadline. */
2199
+ applyGate(effects) {
2200
+ for (const fx of effects) {
2201
+ switch (fx.kind) {
2202
+ case 'deliver':
2203
+ this.forwardBoot(fx.message);
2204
+ break;
2205
+ case 'refuse':
2206
+ this.sendTo(fx.message.ws, {
2207
+ type: 'device:boot-error', sessionId: fx.sessionId, requestId: fx.requestId, reason: fx.reason,
2208
+ message: BOOT_REFUSAL_MESSAGES[fx.reason],
2209
+ });
2210
+ break;
2211
+ case 'waiting':
2212
+ this.sendTo(fx.message.ws, { type: 'device:boot-waiting', sessionId: fx.sessionId, requestId: fx.requestId, until: fx.until, reason: fx.reason });
2213
+ break;
2214
+ case 'withdraw':
2215
+ this.sendTo(fx.message.ws, {
2216
+ type: 'device:boot-error', sessionId: fx.sessionId, requestId: fx.requestId,
2217
+ message: fx.why === 'replaced'
2218
+ ? 'Replaced by a newer boot request for this session'
2219
+ : 'The viewer left while this boot was waiting its turn on this Mac',
2220
+ });
2221
+ break;
2222
+ case 'log':
2223
+ logger.info(`boot gate: ${fx.event} on ${fx.hostKey}${fx.sessionId ? ` (session ${fx.sessionId})` : ''}`);
2224
+ break;
2225
+ default: {
2226
+ const never = fx;
2227
+ throw new Error(`unhandled boot gate effect ${JSON.stringify(never)}`);
2228
+ }
2229
+ }
2230
+ }
2231
+ if (this.bootGateTimer) {
2232
+ clearTimeout(this.bootGateTimer);
2233
+ this.bootGateTimer = null;
2234
+ }
2235
+ const next = this.bootGate.nextDeadline();
2236
+ if (next !== null && !this.stopping) {
2237
+ this.bootGateTimer = setTimeout(() => {
2238
+ this.bootGateTimer = null;
2239
+ this.applyGate(this.bootGate.tick());
2240
+ }, Math.max(0, next - Date.now()));
2241
+ }
2242
+ }
2243
+ /**
2244
+ * Send a boot the gate let through. **Resolved again here** even when the gate answered at once: a held boot can
2245
+ * have outlived its holder's ownership or its agent, and one path for both keeps the two from drifting. A boot
2246
+ * that can no longer go is answered, and the gate is told so the slot it was given is not held for nothing.
2247
+ */
2248
+ forwardBoot({ ws, msg, cost, booterClass }) {
2249
+ const boot = this.dispatchTarget(ws, msg.sessionId);
2250
+ if (!boot.ok) {
2251
+ this.sendTo(ws, { type: 'device:boot-error', sessionId: msg.sessionId, requestId: msg.requestId, message: boot.message });
2252
+ this.gateEvent({ kind: 'boot-error', sessionId: msg.sessionId, requestId: msg.requestId });
2253
+ return;
2254
+ }
2255
+ if (cost === 'cold') {
2256
+ const s = boot.session;
2257
+ // A boot still out keeps the value from before it: `prev` may itself be absent, which is the answer, not a gap.
2258
+ s.lastBootPending = { requestId: msg.requestId, prev: s.lastBootPending ? s.lastBootPending.prev : s.lastBoot };
2259
+ s.lastBoot = { booterClass, at: Date.now() };
2260
+ }
2261
+ // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
2262
+ // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
2263
+ this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
2264
+ // The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
2265
+ boot.session.agentSocket.send(JSON.stringify(msg));
2266
+ }
2076
2267
  bootKey(sessionId, requestId) {
2077
2268
  return `${sessionId}${requestId}`;
2078
2269
  }
@@ -2117,6 +2308,9 @@ export class RelayServer {
2117
2308
  continue;
2118
2309
  this.forgetBoot(key);
2119
2310
  const session = this.sessions.get(pending.sessionId);
2311
+ // The agent that would have answered is gone, so nothing else will settle the record this boot made.
2312
+ if (session)
2313
+ this.undoLastBoot(session, pending.requestId);
2120
2314
  // `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
2121
2315
  // `browserSocket` at all — either way the entry is still gone, so nothing later answers
2122
2316
  // a request nobody holds.
@@ -2243,8 +2437,7 @@ export class RelayServer {
2243
2437
  // better diagnosis — and only a caller that never joined hears from `settleShutdownRequesters`.
2244
2438
  for (const s of agentSessions) {
2245
2439
  this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
2246
- this.sessions.remove(s.id);
2247
- this.forgetSessionState(s.id);
2440
+ this.removeSession(s.id);
2248
2441
  }
2249
2442
  this.sessions.removeResources(ws);
2250
2443
  logger.info(cause === 'replaced'
@@ -2297,6 +2490,7 @@ export class RelayServer {
2297
2490
  if (!device)
2298
2491
  continue;
2299
2492
  this.sessions.rebind(s.id, ws, device, agent);
2493
+ this.gateEvent({ kind: 'rebound', sessionId: s.id });
2300
2494
  rebound.set(s.deviceId, s.id);
2301
2495
  reboundOldSockets.add(old);
2302
2496
  }
@@ -2328,8 +2522,7 @@ export class RelayServer {
2328
2522
  this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
2329
2523
  }
2330
2524
  this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
2331
- this.sessions.remove(s.id);
2332
- this.forgetSessionState(s.id);
2525
+ this.removeSession(s.id);
2333
2526
  }
2334
2527
  }
2335
2528
  // Only devices without a surviving session get a new one. Passing all of them here is what
@@ -2367,6 +2560,179 @@ export class RelayServer {
2367
2560
  // Through `pushLog` too, so `tapflow logs` shows an agent coming back after the line about it leaving.
2368
2561
  this.pushLog(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
2369
2562
  }
2563
+ /** `device:boot`, and the boot a lease sends for the device it chose. */
2564
+ handleBoot(ws, msg) {
2565
+ const boot = this.dispatchTarget(ws, msg.sessionId);
2566
+ if (!boot.ok) {
2567
+ this.sendTo(ws, {
2568
+ type: 'device:boot-error',
2569
+ sessionId: msg.sessionId,
2570
+ requestId: msg.requestId,
2571
+ message: boot.message,
2572
+ });
2573
+ return;
2574
+ }
2575
+ // Tag the boot with whether the viewer is external (public IP) so the agent can pick the
2576
+ // downscale tier. The browser already reports secureContext in the payload.
2577
+ //
2578
+ // **Mutating the parse product, not the frame that arrived**, which is what makes this safe as
2579
+ // well as correct: `payload` is a fresh object the parser built, so `raw` is untouched. The
2580
+ // presence check the old line carried is gone because the schema requires the payload — and
2581
+ // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
2582
+ msg.payload['external'] = this.wsExternal.get(ws) ?? false;
2583
+ msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
2584
+ const cost = classifyBoot(boot.session.deviceStatus, msg.payload, boot.session.deviceServiceTier);
2585
+ const booterClass = isAiClientKind(boot.session.holderKind) ? 'automated' : 'interactive';
2586
+ // The automation cap is decided before the gate and acted on after it: an idle device is shut down to make
2587
+ // room only if this boot actually goes, so a boot the gate refuses costs nobody their device.
2588
+ const capacity = cost === 'cold' && booterClass === 'automated' ? this.planCapacityFor(boot.session) : { ok: true, evict: [] };
2589
+ if (!capacity.ok) {
2590
+ this.sendTo(ws, {
2591
+ type: 'device:boot-error', sessionId: msg.sessionId, requestId: msg.requestId,
2592
+ reason: 'host-at-capacity', message: BOOT_REFUSAL_MESSAGES['host-at-capacity'],
2593
+ });
2594
+ return;
2595
+ }
2596
+ // The boot gate decides whether this goes now, waits (a person), or is refused (automation).
2597
+ const effects = this.bootGate.admit({
2598
+ hostKey: hostKeyOf(boot.session),
2599
+ sessionId: msg.sessionId,
2600
+ requestId: msg.requestId,
2601
+ cost,
2602
+ booterClass,
2603
+ message: { ws, msg, cost, booterClass },
2604
+ });
2605
+ const goes = effects.some((fx) => fx.kind === 'deliver' && fx.requestId === msg.requestId);
2606
+ const critical = effects.some((fx) => fx.kind === 'refuse' && fx.requestId === msg.requestId && fx.reason === 'host-memory-critical');
2607
+ this.applyGate(effects);
2608
+ if (goes)
2609
+ for (const id of capacity.evict)
2610
+ this.evictForCapacity(id);
2611
+ // Idle automation devices are what fills a Mac this way, and while it is critical no boot gets far enough to
2612
+ // shut one down for the cap. So the refusal itself frees one — the oldest idle, never one in use, and not
2613
+ // while an earlier one on this Mac is still shutting down.
2614
+ else if (critical)
2615
+ this.freeOneForMemory(boot.session);
2616
+ }
2617
+ /**
2618
+ * `device:lease`: choose a device, make this client its holder and boot it, in this one call. Nothing here is
2619
+ * awaited, so no other message can take the device between the choice and the join — that is what makes the lease
2620
+ * atomic, and why the two steps are not two messages.
2621
+ *
2622
+ * Every check the join and the boot will make is asked first, without recording anything (`wouldRefuseAutomated`,
2623
+ * `planCapacityFor`, `resourceLoad`), so the device chosen is one both will let through and nothing is undone on a
2624
+ * Mac passed over. Then the ordinary join and the ordinary boot run, so a lease is held to exactly what a client
2625
+ * naming the device would be.
2626
+ */
2627
+ handleLease(ws, msg) {
2628
+ const { requestId } = msg;
2629
+ const refuse = (reason, message) => this.sendTo(ws, {
2630
+ type: 'device:lease-refused', requestId, reason, message, retryable: reason !== 'no-device' && reason !== 'bad-request',
2631
+ });
2632
+ const { payload } = msg;
2633
+ let platform = payload.platform;
2634
+ if (payload.buildId !== undefined) {
2635
+ const build = getDb().prepare('SELECT platform FROM builds WHERE id = ?').get(payload.buildId);
2636
+ if (!build)
2637
+ return refuse('bad-request', `build ${payload.buildId} not found`);
2638
+ if (build.platform === 'ios' || build.platform === 'android')
2639
+ platform = build.platform;
2640
+ }
2641
+ if (!platform)
2642
+ return refuse('bad-request', 'no platform: name one, or a build this relay has');
2643
+ const excluded = new Set(payload.exclude ?? []);
2644
+ const now = Date.now();
2645
+ const ofPlatform = this.sessions.all().filter((s) => s.devicePlatform === platform && !excluded.has(s.id));
2646
+ if (ofPlatform.length === 0)
2647
+ return refuse('no-device', `no ${platform} device on this relay`);
2648
+ // Boot records nobody answered expire where the cap reads them (`capacityDevices`), which a lease meeting only
2649
+ // devices already up would never reach; an expired one would otherwise hide its device from every lease.
2650
+ for (const key of new Set(ofPlatform.map(hostKeyOf)))
2651
+ this.capacityDevices(this.sessions.getAllOnHost(key, hostKeyOf));
2652
+ const tier = payload.serviceTier ? { serviceTier: payload.serviceTier } : {};
2653
+ const candidates = [];
2654
+ const sessionOf = new Map();
2655
+ for (const s of ofPlatform) {
2656
+ if (s.agentSocket.readyState !== WebSocket.OPEN)
2657
+ continue;
2658
+ if (s.owner !== null || this.sessions.watchersOf(s.id).length > 0)
2659
+ continue;
2660
+ if (s.evictingAt !== undefined && now - s.evictingAt < this.evictionGraceMs)
2661
+ continue;
2662
+ if (s.lastBootPending !== undefined)
2663
+ continue;
2664
+ // A person's idle countdown: theirs to come back to. Leasing it would wipe it with the run's reset.
2665
+ if (s.idleTimer !== null && !s.lastHolderWasAi)
2666
+ continue;
2667
+ // Off, or up because automation brought it up through this relay. A simulator opened by hand, or one up before
2668
+ // a relay restart, has no `lastBoot`, and one booting or shutting down reads `unknown`: any of them may be
2669
+ // someone's, so none is ours to reset. Not `readySent`: one of ours whose agent reconnected has lost it, and
2670
+ // the lease's boot binds it again.
2671
+ const ours = s.deviceStatus === 'booted' && s.lastBoot?.booterClass === 'automated';
2672
+ if (s.deviceStatus !== 'shutdown' && !ours)
2673
+ continue;
2674
+ // The boot's own judgement, so the lease cannot disagree with it: one of ours on another tier is restarted onto
2675
+ // the one asked for, which is a cold boot like any other and goes through the same checks.
2676
+ const pool = ours && classifyBoot(s.deviceStatus, tier, s.deviceServiceTier) === 'warm';
2677
+ sessionOf.set(s.id, s);
2678
+ candidates.push({ sessionId: s.id, hostKey: hostKeyOf(s), pool, bootedAt: s.lastBoot?.at ?? 0, refusal: this.leaseRefusal(s, pool) });
2679
+ }
2680
+ if (candidates.length === 0)
2681
+ return refuse('all-held', `every ${platform} device is in use`);
2682
+ const choice = chooseLease(candidates, this.leaseHosts(candidates));
2683
+ if (!choice.ok) {
2684
+ const onCritical = choice.criticalHost !== undefined ? candidates.find((c) => c.hostKey === choice.criticalHost) : undefined;
2685
+ if (onCritical)
2686
+ this.freeOneForMemory(sessionOf.get(onCritical.sessionId));
2687
+ return refuse(choice.reason, choice.reason === 'agent-resources-exhausted' ? 'Agent resources exhausted' : BOOT_REFUSAL_MESSAGES[choice.reason]);
2688
+ }
2689
+ const session = sessionOf.get(choice.sessionId);
2690
+ this.sendTo(ws, {
2691
+ type: 'device:leased', requestId, sessionId: session.id, deviceId: session.deviceId, deviceName: session.deviceName,
2692
+ platform, source: candidates.find((c) => c.sessionId === session.id).pool ? 'pool' : 'boot',
2693
+ });
2694
+ this.handleSessionStart(ws, { type: 'session:start', sessionId: session.id, clientKind: 'flow-runner', ...(payload.stream === false ? { stream: false } : {}) });
2695
+ // The checks above are the join's own, so this holds; if it ever does not, the join has answered the client.
2696
+ if (!this.ownsSession(ws, session))
2697
+ return;
2698
+ this.handleBoot(ws, {
2699
+ type: 'device:boot', sessionId: session.id, requestId,
2700
+ payload: {
2701
+ deviceId: session.deviceId,
2702
+ ...(payload.acceptH264 ? { acceptH264: true } : {}),
2703
+ ...(payload.serviceTier ? { serviceTier: payload.serviceTier } : {}),
2704
+ ...(payload.runReset ? { runReset: payload.runReset } : {}),
2705
+ },
2706
+ });
2707
+ }
2708
+ /** What the join and the boot would refuse an AI client taking this device for, asked without recording anything.
2709
+ * The join's CPU and memory checks, then for a cold boot the automation cap and the boot gate, in their order. */
2710
+ leaseRefusal(s, pool) {
2711
+ const load = this.resourceLoad(s);
2712
+ if (load && (load.cpuOver || load.memOver))
2713
+ return 'agent-resources-exhausted';
2714
+ if (pool)
2715
+ return null;
2716
+ if (!this.planCapacityFor(s).ok)
2717
+ return 'host-at-capacity';
2718
+ return this.bootGate.wouldRefuseAutomated(hostKeyOf(s));
2719
+ }
2720
+ /** Each candidate Mac's room, from its agents' latest report. Not corrected for a boot the report does not show
2721
+ * yet: a boot in flight holds its Mac's slot, so it already keeps every further boot off that Mac, and memory is
2722
+ * only ever compared between Macs that could take one. */
2723
+ leaseHosts(candidates) {
2724
+ const hosts = new Map();
2725
+ for (const key of new Set(candidates.map((c) => c.hostKey))) {
2726
+ const onHost = this.sessions.getAllOnHost(key, hostKeyOf);
2727
+ const reports = [...new Set(onHost.map((s) => s.agentSocket))].map((a) => this.sessions.getResources(a)).filter((r) => r !== undefined);
2728
+ const latest = reports.sort((x, y) => y.reportedAt - x.reportedAt)[0];
2729
+ const automated = this.capacityDevices(onHost).filter((d) => d.automated).length;
2730
+ hosts.set(key, latest
2731
+ ? { freeMB: latest.memTotalMB - latest.memUsedMB, cpuPercent: latest.cpuPercent, automated }
2732
+ : { freeMB: null, cpuPercent: null, automated });
2733
+ }
2734
+ return hosts;
2735
+ }
2370
2736
  /** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
2371
2737
  *
2372
2738
  * That is what makes the address possible rather than aspirational: `msg.sessionId` is narrowed to a
@@ -2412,11 +2778,22 @@ export class RelayServer {
2412
2778
  // Read before the resource gate, and answered before it too. The gate would otherwise read the
2413
2779
  // dead socket's last sample — and an overloaded Mac is a common reason to restart an agent, so
2414
2780
  // the tester would be told the Mac is exhausted at the exact moment it is recovering.
2781
+ //
2782
+ // A person rejoining a device this relay has up is not held to the memory half. The dashboard re-sends
2783
+ // `session:start` on every socket reconnect and the close already cleared the owner, so the tester coming
2784
+ // back reads as a newcomer — and once other devices had pushed memory over the line, a Wi-Fi blink lost
2785
+ // them the device they were using. Booting is what spends the memory, and this one is up.
2786
+ //
2787
+ // `readySent`, not `deviceStatus`: a simulator booted before the agent registered says `booted` without
2788
+ // this relay ever bringing it up (#440), so the viewer's next `device:boot` would be a full boot let in on
2789
+ // a full Mac. An AI client keeps the memory half: its boot warms the UI-tree runner, which does cost
2790
+ // memory on a device already up. The CPU half applies to every join — a join usually starts a stream, and a
2791
+ // stream's encoding is the load #107 is about.
2415
2792
  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) {
2793
+ const load = this.resourceLoad(session);
2794
+ if (load) {
2795
+ const memSpent = session.readySent && !isAiClientKind(msg.clientKind);
2796
+ if (load.cpuOver || (load.memOver && !memSpent)) {
2420
2797
  this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Agent resources exhausted', reason: 'agent-resources-exhausted' });
2421
2798
  return;
2422
2799
  }
@@ -2683,6 +3060,100 @@ export class RelayServer {
2683
3060
  payload: { deviceId: session.deviceId },
2684
3061
  });
2685
3062
  }
3063
+ /**
3064
+ * Put `lastBoot` right once the agent says what the boot was. A boot charged cold that found the device running
3065
+ * started nothing, so the record goes back; a boot let through warm that had to start the device after all is
3066
+ * the holder's.
3067
+ */
3068
+ settleLastBoot(session, requestId, cold) {
3069
+ const pending = session.lastBootPending;
3070
+ if (pending && pending.requestId === requestId) {
3071
+ if (cold === false)
3072
+ session.lastBoot = pending.prev;
3073
+ session.lastBootPending = undefined;
3074
+ }
3075
+ else if (cold === true) {
3076
+ const expired = session.lastBootExpired?.requestId === requestId ? session.lastBootExpired : undefined;
3077
+ const booterClass = expired?.booterClass ?? (isAiClientKind(session.holderKind) ? 'automated' : 'interactive');
3078
+ session.lastBoot = { booterClass, at: Date.now() };
3079
+ // An earlier boot of this session brought the device up while a newer one is still out: that newer one
3080
+ // turning out warm must fall back to this, not to before either.
3081
+ if (pending)
3082
+ pending.prev = session.lastBoot;
3083
+ }
3084
+ }
3085
+ /** A boot that did not bring the device up (failed, or stranded by a rebind) says nothing about who started it. */
3086
+ undoLastBoot(session, requestId) {
3087
+ if (session.lastBootPending && session.lastBootPending.requestId === requestId) {
3088
+ session.lastBoot = session.lastBootPending.prev;
3089
+ session.lastBootPending = undefined;
3090
+ }
3091
+ }
3092
+ /** How many automation devices this Mac may keep up: `TAPFLOW_MAX_AUTOMATED_DEVICES`, else from its memory, else
3093
+ * no cap while no agent there has reported any. */
3094
+ automatedCap(onHost) {
3095
+ if (this.automatedCapOverride !== null)
3096
+ return this.automatedCapOverride;
3097
+ let latest;
3098
+ for (const s of onHost) {
3099
+ const r = this.sessions.getResources(s.agentSocket);
3100
+ if (r && (!latest || r.reportedAt > latest.reportedAt))
3101
+ latest = r;
3102
+ }
3103
+ return latest ? defaultCap(latest.memTotalMB) : Number.POSITIVE_INFINITY;
3104
+ }
3105
+ planCapacityFor(requester) {
3106
+ const onHost = this.sessions.getAllOnHost(hostKeyOf(requester), hostKeyOf);
3107
+ return planCapacity(this.capacityDevices(onHost), { sessionId: requester.id, platform: requester.devicePlatform }, this.automatedCap(onHost));
3108
+ }
3109
+ capacityDevices(onHost) {
3110
+ const now = Date.now();
3111
+ // A boot nobody has answered within the correlator's TTL is taken as not having happened. Checked here rather
3112
+ // than on that timer, which a leave or an end clears with the session's other state while the record stays;
3113
+ // a late ready still records the boot again (`settleLastBoot` on `cold: true`).
3114
+ for (const s of onHost) {
3115
+ if (s.lastBootPending && s.lastBoot && now - s.lastBoot.at >= this.pendingBootTtlMs) {
3116
+ s.lastBootExpired = { requestId: s.lastBootPending.requestId, booterClass: s.lastBoot.booterClass };
3117
+ this.undoLastBoot(s, s.lastBootPending.requestId);
3118
+ }
3119
+ }
3120
+ return onHost
3121
+ // A device being shut down to make room is neither up nor available, until the agent says otherwise.
3122
+ .filter((s) => s.evictingAt === undefined || now - s.evictingAt >= this.evictionGraceMs)
3123
+ .map((s) => ({
3124
+ sessionId: s.id,
3125
+ platform: s.devicePlatform,
3126
+ // Up, or being started by automation now: `deviceStatus` reads `shutdown` while a boot runs, so the
3127
+ // boot in flight is known by its pending record instead. An eviction past its grace with no answer from
3128
+ // the agent (filtered above while within it) is taken as not having happened: still up, and idle.
3129
+ automated: s.lastBoot?.booterClass === 'automated' && (s.deviceStatus === 'booted' || s.evictingAt !== undefined || s.lastBootPending !== undefined),
3130
+ booted: (s.deviceStatus === 'booted' || s.evictingAt !== undefined) && s.lastBootPending === undefined,
3131
+ inUse: s.owner !== null || this.sessions.watchersOf(s.id).length > 0,
3132
+ reachable: s.agentSocket.readyState === WebSocket.OPEN,
3133
+ bootedAt: s.lastBoot?.at ?? 0,
3134
+ }));
3135
+ }
3136
+ freeOneForMemory(requester) {
3137
+ const onHost = this.sessions.getAllOnHost(hostKeyOf(requester), hostKeyOf);
3138
+ const now = Date.now();
3139
+ if (onHost.some((s) => s.evictingAt !== undefined && now - s.evictingAt < this.evictionGraceMs))
3140
+ return;
3141
+ const victim = idleToFree(this.capacityDevices(onHost), requester.id);
3142
+ if (victim)
3143
+ this.evictForCapacity(victim);
3144
+ }
3145
+ /** Shut down an idle automation device to make room. Marked first, so nothing replays it as up or picks it again
3146
+ * while the agent works; the mark lifts on `shutdown-done`, `shutdown-error` or after `evictionGraceMs`. */
3147
+ evictForCapacity(sessionId) {
3148
+ const session = this.sessions.get(sessionId);
3149
+ if (session?.agentSocket.readyState !== WebSocket.OPEN)
3150
+ return;
3151
+ session.evictingAt = Date.now();
3152
+ this.sessions.updateDeviceStatus(session.id, 'shutdown');
3153
+ this.sessions.setReadySent(session.id, false);
3154
+ logger.info(`boot gate: shutting down idle automation device ${session.deviceName} on ${hostKeyOf(session)} to make room`);
3155
+ this.sendTo(session.agentSocket, { type: 'device:shutdown', sessionId: session.id, payload: { deviceId: session.deviceId } });
3156
+ }
2686
3157
  /** Where a browser-role command can be dispatched, or what its sender needs to be told instead.
2687
3158
  *
2688
3159
  * Three refusals used to be spread across seven `case` bodies as two conditions each, and the ownership
@@ -3051,7 +3522,7 @@ export class RelayServer {
3051
3522
  // fifth: a throw out of `getDb().prepare(…).get(…)` — SQLITE_BUSY, a closed db, I/O — unwinds to the
3052
3523
  // message-loop catch and answers nothing at all. Pre-existing and unchanged here, but "every exit
3053
3524
  // carries the request's id" would be false.
3054
- const fail = (message) => this.sendTo(ws, { type: 'app:launch-error', sessionId, requestId, message });
3525
+ const fail = (message, reason) => this.sendTo(ws, { type: 'app:launch-error', sessionId, requestId, message, ...(reason ? { reason } : {}) });
3055
3526
  const session = this.sessions.get(sessionId);
3056
3527
  if (!session)
3057
3528
  return fail('Session not found');
@@ -3070,13 +3541,34 @@ export class RelayServer {
3070
3541
  return fail('Bundle ID not available for this build');
3071
3542
  if (session.agentSocket.readyState !== WebSocket.OPEN)
3072
3543
  return fail('agent offline');
3544
+ // The door checked the shape; this is the same rule every producer applies, answered with the key and the reason.
3545
+ const problem = launchOptionsProblem({ arguments: msg.arguments, env: msg.env });
3546
+ if (problem)
3547
+ return fail(problem);
3548
+ const caller = compactLaunchOptions({ arguments: msg.arguments, env: msg.env });
3549
+ // An agent that predates the options ignores both fields and launches without them, then answers done.
3550
+ if (caller && !hasCapability(session.agentCapabilities, 'launch-options')) {
3551
+ return fail('This device\'s tapflow agent is too old to pass launch arguments. Update tapflow on the Mac running it', 'agent-outdated');
3552
+ }
3553
+ // `Object.hasOwn` is not needed: a Map holds only what the config named, whatever the uploaded id is called.
3554
+ const merged = mergeLaunch(this.options.launchDefaults?.get(build.bundle_id), caller, {
3555
+ options: hasCapability(session.agentCapabilities, 'launch-options'),
3556
+ env: hasCapability(session.agentCapabilities, 'launch-env'),
3557
+ });
3558
+ if (merged.dropped)
3559
+ this.warnDroppedDefaults(build.bundle_id, merged.dropped);
3560
+ // Each side passed on its own; together they can exceed the limits.
3561
+ const together = merged.options && caller ? launchOptionsProblem(merged.options) : null;
3562
+ if (together)
3563
+ return fail(`${together}, counting this app's launch defaults in tapflow.config.json`);
3564
+ const options = merged.options;
3073
3565
  // See `handleBrowserAppInstall` — the correlator rides across the rebuild, and only a test says the
3074
3566
  // value is the request's.
3075
3567
  this.sendTo(session.agentSocket, {
3076
3568
  type: 'app:launch',
3077
3569
  sessionId,
3078
3570
  requestId,
3079
- payload: { bundleId: build.bundle_id },
3571
+ payload: { bundleId: build.bundle_id, ...options, ...(merged.defaultsOnly ? { defaultsOnly: true } : {}) },
3080
3572
  });
3081
3573
  this.tellWatchersAction(sessionId, { kind: 'launch' });
3082
3574
  }