@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.
- package/dist/RelayServer.d.ts +75 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +496 -49
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +41 -2
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +31 -1
- package/dist/SessionManager.js.map +1 -1
- package/dist/admission/HostBootGate.d.ts +181 -0
- package/dist/admission/HostBootGate.d.ts.map +1 -0
- package/dist/admission/HostBootGate.js +388 -0
- package/dist/admission/HostBootGate.js.map +1 -0
- package/dist/admission/capacity.d.ts +51 -0
- package/dist/admission/capacity.d.ts.map +1 -0
- package/dist/admission/capacity.js +49 -0
- package/dist/admission/capacity.js.map +1 -0
- package/dist/admission/classifyBoot.d.ts +17 -0
- package/dist/admission/classifyBoot.d.ts.map +1 -0
- package/dist/admission/classifyBoot.js +23 -0
- package/dist/admission/classifyBoot.js.map +1 -0
- package/dist/admission/hostKey.d.ts +10 -0
- package/dist/admission/hostKey.d.ts.map +1 -0
- package/dist/admission/hostKey.js +13 -0
- package/dist/admission/hostKey.js.map +1 -0
- package/dist/admission/leaseChoice.d.ts +38 -0
- package/dist/admission/leaseChoice.d.ts.map +1 -0
- package/dist/admission/leaseChoice.js +40 -0
- package/dist/admission/leaseChoice.js.map +1 -0
- package/dist/admission/settle.d.ts +35 -0
- package/dist/admission/settle.d.ts.map +1 -0
- package/dist/admission/settle.js +41 -0
- package/dist/admission/settle.js.map +1 -0
- package/dist/api/runs.d.ts +21 -2
- package/dist/api/runs.d.ts.map +1 -1
- package/dist/api/runs.js +181 -26
- package/dist/api/runs.js.map +1 -1
- package/dist/migrations/017_flow_run_skipped.sql +34 -0
- package/dist/migrations/018_flow_run_service_tier.sql +6 -0
- package/dist/migrations/019_flow_run_device_reset.sql +7 -0
- package/package.json +4 -4
- package/public/assets/{AISessionWatch-C2h4NZ_I.js → AISessionWatch-BSBkpTom.js} +7 -7
- package/public/assets/AISessionWatch-BSBkpTom.js.br +0 -0
- package/public/assets/AISessionWatch-BSBkpTom.js.gz +0 -0
- package/public/assets/{AppCenter-S1y-8_7l.js → AppCenter-DD_6LQx-.js} +6 -6
- package/public/assets/AppCenter-DD_6LQx-.js.br +0 -0
- package/public/assets/AppCenter-DD_6LQx-.js.gz +0 -0
- package/public/assets/{Default-C-l1O-g9.js → Default-CQ3Ae2mK.js} +1 -1
- package/public/assets/Default-CQ3Ae2mK.js.br +0 -0
- package/public/assets/Default-CQ3Ae2mK.js.gz +0 -0
- package/public/assets/{Invite-BCndVxaO.js → Invite-D1uNMm0-.js} +1 -1
- package/public/assets/Invite-D1uNMm0-.js.br +0 -0
- package/public/assets/Invite-D1uNMm0-.js.gz +0 -0
- package/public/assets/{MacResources-41cFDgE1.js → MacResources-D2UeRbKL.js} +1 -1
- package/public/assets/MacResources-D2UeRbKL.js.br +0 -0
- package/public/assets/MacResources-D2UeRbKL.js.gz +0 -0
- package/public/assets/QASession-d0eOjXXo.js +81 -0
- package/public/assets/QASession-d0eOjXXo.js.br +0 -0
- package/public/assets/QASession-d0eOjXXo.js.gz +0 -0
- package/public/assets/{ResetPassword-8rj7ASt1.js → ResetPassword-B-4Uyfwr.js} +1 -1
- package/public/assets/ResetPassword-B-4Uyfwr.js.br +0 -0
- package/public/assets/ResetPassword-B-4Uyfwr.js.gz +0 -0
- package/public/assets/RunDetail-C50RjsuM.js +16 -0
- package/public/assets/RunDetail-C50RjsuM.js.br +0 -0
- package/public/assets/RunDetail-C50RjsuM.js.gz +0 -0
- package/public/assets/Runs-A7RcDLgO.js +6 -0
- package/public/assets/Runs-A7RcDLgO.js.br +0 -0
- package/public/assets/Runs-A7RcDLgO.js.gz +0 -0
- package/public/assets/{Setup-DZrhA_7b.js → Setup-DK6TZbwh.js} +1 -1
- package/public/assets/Setup-DK6TZbwh.js.br +0 -0
- package/public/assets/Setup-DK6TZbwh.js.gz +0 -0
- package/public/assets/{Team-CJThCjd3.js → Team-VoGput2p.js} +2 -2
- package/public/assets/Team-VoGput2p.js.br +0 -0
- package/public/assets/Team-VoGput2p.js.gz +0 -0
- package/public/assets/{Tokens-DGURtHWi.js → Tokens-qkcJgunR.js} +1 -1
- package/public/assets/Tokens-qkcJgunR.js.br +0 -0
- package/public/assets/Tokens-qkcJgunR.js.gz +0 -0
- package/public/assets/{aiSession-BW09Jhqz.js → aiSession-U7UqgdSb.js} +6 -6
- package/public/assets/aiSession-U7UqgdSb.js.br +0 -0
- package/public/assets/aiSession-U7UqgdSb.js.gz +0 -0
- package/public/assets/{alert-dialog-t5vlOabq.js → alert-dialog-C0mRms8g.js} +1 -1
- package/public/assets/{alert-dialog-t5vlOabq.js.br → alert-dialog-C0mRms8g.js.br} +0 -0
- package/public/assets/alert-dialog-C0mRms8g.js.gz +0 -0
- package/public/assets/{badge-DrDCTAQQ.js → badge-BjEnOmb0.js} +1 -1
- package/public/assets/badge-BjEnOmb0.js.br +0 -0
- package/public/assets/badge-BjEnOmb0.js.gz +0 -0
- package/public/assets/{dialog-BOFZkCES.js → dialog-Cugbp1C4.js} +1 -1
- package/public/assets/dialog-Cugbp1C4.js.br +0 -0
- package/public/assets/dialog-Cugbp1C4.js.gz +0 -0
- package/public/assets/{field-hint-B9fPUgi7.js → field-hint-qmBa0qqN.js} +1 -1
- package/public/assets/field-hint-qmBa0qqN.js.br +0 -0
- package/public/assets/field-hint-qmBa0qqN.js.gz +0 -0
- package/public/assets/{index-Bixhy5MB.js → index-C_3Q51YJ.js} +39 -39
- package/public/assets/index-C_3Q51YJ.js.br +0 -0
- package/public/assets/index-C_3Q51YJ.js.gz +0 -0
- package/public/assets/{list-view-BpOY1e-d.js → list-view-CyM0QUaH.js} +1 -1
- package/public/assets/list-view-CyM0QUaH.js.br +0 -0
- package/public/assets/list-view-CyM0QUaH.js.gz +0 -0
- package/public/assets/{pencil-ZlrAsWuU.js → pencil-DUZRZB3S.js} +2 -2
- package/public/assets/pencil-DUZRZB3S.js.br +0 -0
- package/public/assets/pencil-DUZRZB3S.js.gz +0 -0
- package/public/assets/{play-4CsGZdQU.js → play-Z1L2Eb91.js} +2 -2
- package/public/assets/play-Z1L2Eb91.js.br +0 -0
- package/public/assets/play-Z1L2Eb91.js.gz +0 -0
- package/public/assets/{runs-CInQb6v1.js → runs-CFQ6M4rx.js} +1 -1
- package/public/assets/runs-CFQ6M4rx.js.br +0 -0
- package/public/assets/runs-CFQ6M4rx.js.gz +0 -0
- package/public/assets/{search-input-G_QpBvPM.js → search-input-B0s9mzRt.js} +2 -2
- package/public/assets/search-input-B0s9mzRt.js.br +0 -0
- package/public/assets/search-input-B0s9mzRt.js.gz +0 -0
- package/public/assets/{select-DDzv2YfY.js → select-CHbE6M4S.js} +3 -3
- package/public/assets/select-CHbE6M4S.js.br +0 -0
- package/public/assets/select-CHbE6M4S.js.gz +0 -0
- package/public/assets/{slider-DE7lQdK4.js → slider-tyb3JvL2.js} +1 -1
- package/public/assets/slider-tyb3JvL2.js.br +0 -0
- package/public/assets/slider-tyb3JvL2.js.gz +0 -0
- package/public/assets/{tabs-Co1N3x_X.js → tabs-Bf7ou-eS.js} +1 -1
- package/public/assets/tabs-Bf7ou-eS.js.br +0 -0
- package/public/assets/tabs-Bf7ou-eS.js.gz +0 -0
- package/public/assets/{tech-label-BMvuJmUf.js → tech-label-KAwM5qZ_.js} +1 -1
- package/public/assets/tech-label-KAwM5qZ_.js.br +0 -0
- package/public/assets/tech-label-KAwM5qZ_.js.gz +0 -0
- package/public/assets/{trash-Cwj2dbgB.js → trash-HEJmY7DQ.js} +3 -3
- package/public/assets/trash-HEJmY7DQ.js.br +0 -0
- package/public/assets/trash-HEJmY7DQ.js.gz +0 -0
- package/public/assets/{video-swsaHrfi.js → video-DdHH0Q0l.js} +2 -2
- package/public/assets/video-DdHH0Q0l.js.br +0 -0
- package/public/assets/video-DdHH0Q0l.js.gz +0 -0
- package/public/index.html +1 -1
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
- package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
- package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
- package/public/assets/AppCenter-S1y-8_7l.js.gz +0 -0
- package/public/assets/Default-C-l1O-g9.js.br +0 -0
- package/public/assets/Default-C-l1O-g9.js.gz +0 -0
- package/public/assets/Invite-BCndVxaO.js.br +0 -0
- package/public/assets/Invite-BCndVxaO.js.gz +0 -0
- package/public/assets/MacResources-41cFDgE1.js.br +0 -0
- package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
- package/public/assets/QASession-B67RsIPm.js +0 -81
- package/public/assets/QASession-B67RsIPm.js.br +0 -0
- package/public/assets/QASession-B67RsIPm.js.gz +0 -0
- package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
- package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
- package/public/assets/RunDetail-BIgq9gJ0.js +0 -16
- package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
- package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
- package/public/assets/Runs-DpK5aoBr.js +0 -6
- package/public/assets/Runs-DpK5aoBr.js.br +0 -0
- package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
- package/public/assets/Setup-DZrhA_7b.js.br +0 -0
- package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
- package/public/assets/Team-CJThCjd3.js.br +0 -0
- package/public/assets/Team-CJThCjd3.js.gz +0 -0
- package/public/assets/Tokens-DGURtHWi.js.br +0 -0
- package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
- package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
- package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
- package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
- package/public/assets/badge-DrDCTAQQ.js.br +0 -0
- package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
- package/public/assets/dialog-BOFZkCES.js.br +0 -0
- package/public/assets/dialog-BOFZkCES.js.gz +0 -0
- package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
- package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
- package/public/assets/index-Bixhy5MB.js.br +0 -0
- package/public/assets/index-Bixhy5MB.js.gz +0 -0
- package/public/assets/list-view-BpOY1e-d.js.br +0 -0
- package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
- package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
- package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
- package/public/assets/play-4CsGZdQU.js.br +0 -0
- package/public/assets/play-4CsGZdQU.js.gz +0 -0
- package/public/assets/runs-CInQb6v1.js.br +0 -0
- package/public/assets/runs-CInQb6v1.js.gz +0 -0
- package/public/assets/search-input-G_QpBvPM.js.br +0 -0
- package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
- package/public/assets/select-DDzv2YfY.js.br +0 -0
- package/public/assets/select-DDzv2YfY.js.gz +0 -0
- package/public/assets/slider-DE7lQdK4.js.br +0 -0
- package/public/assets/slider-DE7lQdK4.js.gz +0 -0
- package/public/assets/tabs-Co1N3x_X.js.br +0 -0
- package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
- package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
- package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
- package/public/assets/trash-Cwj2dbgB.js.br +0 -0
- package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
- package/public/assets/video-swsaHrfi.js.br +0 -0
- package/public/assets/video-swsaHrfi.js.gz +0 -0
package/dist/RelayServer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
1550
|
-
this.forgetSessionState(msg.sessionId);
|
|
1587
|
+
this.removeSession(msg.sessionId);
|
|
1551
1588
|
}
|
|
1552
1589
|
break;
|
|
1553
1590
|
}
|
|
1554
1591
|
case 'session:leave': {
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
2417
|
-
if (
|
|
2418
|
-
const
|
|
2419
|
-
if (
|
|
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
|