@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.
- package/dist/RelayServer.d.ts +81 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +543 -51
- 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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +14 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +98 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/launchDefaults.d.ts +25 -0
- package/dist/lib/launchDefaults.d.ts.map +1 -0
- package/dist/lib/launchDefaults.js +41 -0
- package/dist/lib/launchDefaults.js.map +1 -0
- 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/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/package.json +4 -4
- package/public/assets/{AISessionWatch-C2h4NZ_I.js → AISessionWatch-DxsyhjAy.js} +7 -7
- package/public/assets/AISessionWatch-DxsyhjAy.js.br +0 -0
- package/public/assets/AISessionWatch-DxsyhjAy.js.gz +0 -0
- package/public/assets/{AppCenter-S1y-8_7l.js → AppCenter-CZMTgfBh.js} +6 -6
- package/public/assets/AppCenter-CZMTgfBh.js.br +0 -0
- package/public/assets/{AppCenter-S1y-8_7l.js.gz → AppCenter-CZMTgfBh.js.gz} +0 -0
- package/public/assets/{Default-C-l1O-g9.js → Default-DCk7wCIg.js} +1 -1
- package/public/assets/Default-DCk7wCIg.js.br +0 -0
- package/public/assets/Default-DCk7wCIg.js.gz +0 -0
- package/public/assets/{Invite-BCndVxaO.js → Invite-BNgu8qb9.js} +1 -1
- package/public/assets/Invite-BNgu8qb9.js.br +0 -0
- package/public/assets/Invite-BNgu8qb9.js.gz +0 -0
- package/public/assets/{MacResources-41cFDgE1.js → MacResources-B26lSN5B.js} +1 -1
- package/public/assets/MacResources-B26lSN5B.js.br +0 -0
- package/public/assets/MacResources-B26lSN5B.js.gz +0 -0
- package/public/assets/QASession-Rin1Ol-2.js +81 -0
- package/public/assets/QASession-Rin1Ol-2.js.br +0 -0
- package/public/assets/QASession-Rin1Ol-2.js.gz +0 -0
- package/public/assets/{ResetPassword-8rj7ASt1.js → ResetPassword-CQsALVBn.js} +1 -1
- package/public/assets/ResetPassword-CQsALVBn.js.br +0 -0
- package/public/assets/ResetPassword-CQsALVBn.js.gz +0 -0
- package/public/assets/RunDetail-D0JKuXAS.js +16 -0
- package/public/assets/RunDetail-D0JKuXAS.js.br +0 -0
- package/public/assets/RunDetail-D0JKuXAS.js.gz +0 -0
- package/public/assets/Runs-DSHpDs0m.js +6 -0
- package/public/assets/Runs-DSHpDs0m.js.br +0 -0
- package/public/assets/Runs-DSHpDs0m.js.gz +0 -0
- package/public/assets/{Setup-DZrhA_7b.js → Setup-4-HC1rwb.js} +1 -1
- package/public/assets/Setup-4-HC1rwb.js.br +0 -0
- package/public/assets/Setup-4-HC1rwb.js.gz +0 -0
- package/public/assets/{Team-CJThCjd3.js → Team-CgJM95TR.js} +2 -2
- package/public/assets/Team-CgJM95TR.js.br +0 -0
- package/public/assets/Team-CgJM95TR.js.gz +0 -0
- package/public/assets/{Tokens-DGURtHWi.js → Tokens-D2wbiDVG.js} +1 -1
- package/public/assets/Tokens-D2wbiDVG.js.br +0 -0
- package/public/assets/Tokens-D2wbiDVG.js.gz +0 -0
- package/public/assets/{aiSession-BW09Jhqz.js → aiSession-DE80E1QU.js} +6 -6
- package/public/assets/aiSession-DE80E1QU.js.br +0 -0
- package/public/assets/aiSession-DE80E1QU.js.gz +0 -0
- package/public/assets/{alert-dialog-t5vlOabq.js → alert-dialog-nxQTqXgk.js} +1 -1
- package/public/assets/alert-dialog-nxQTqXgk.js.br +0 -0
- package/public/assets/alert-dialog-nxQTqXgk.js.gz +0 -0
- package/public/assets/{badge-DrDCTAQQ.js → badge-DM1m4CLq.js} +1 -1
- package/public/assets/badge-DM1m4CLq.js.br +0 -0
- package/public/assets/badge-DM1m4CLq.js.gz +0 -0
- package/public/assets/{dialog-BOFZkCES.js → dialog-CTc84wr4.js} +1 -1
- package/public/assets/dialog-CTc84wr4.js.br +0 -0
- package/public/assets/dialog-CTc84wr4.js.gz +0 -0
- package/public/assets/{field-hint-B9fPUgi7.js → field-hint-xptj_gfn.js} +1 -1
- package/public/assets/field-hint-xptj_gfn.js.br +0 -0
- package/public/assets/field-hint-xptj_gfn.js.gz +0 -0
- package/public/assets/{index-Bixhy5MB.js → index-2zL1a5_0.js} +39 -39
- package/public/assets/index-2zL1a5_0.js.br +0 -0
- package/public/assets/index-2zL1a5_0.js.gz +0 -0
- package/public/assets/{list-view-BpOY1e-d.js → list-view-BzI8tLvX.js} +1 -1
- package/public/assets/list-view-BzI8tLvX.js.br +0 -0
- package/public/assets/list-view-BzI8tLvX.js.gz +0 -0
- package/public/assets/{pencil-ZlrAsWuU.js → pencil-CEWs8Of9.js} +2 -2
- package/public/assets/pencil-CEWs8Of9.js.br +0 -0
- package/public/assets/pencil-CEWs8Of9.js.gz +0 -0
- package/public/assets/{play-4CsGZdQU.js → play-Dab2J3Tw.js} +2 -2
- package/public/assets/play-Dab2J3Tw.js.br +0 -0
- package/public/assets/play-Dab2J3Tw.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-BK7cFT1m.js} +2 -2
- package/public/assets/search-input-BK7cFT1m.js.br +0 -0
- package/public/assets/search-input-BK7cFT1m.js.gz +0 -0
- package/public/assets/{select-DDzv2YfY.js → select-BH7E2Q0r.js} +3 -3
- package/public/assets/select-BH7E2Q0r.js.br +0 -0
- package/public/assets/select-BH7E2Q0r.js.gz +0 -0
- package/public/assets/{slider-DE7lQdK4.js → slider-B4sPZi8m.js} +1 -1
- package/public/assets/slider-B4sPZi8m.js.br +0 -0
- package/public/assets/slider-B4sPZi8m.js.gz +0 -0
- package/public/assets/{tabs-Co1N3x_X.js → tabs-CYoB-S2t.js} +1 -1
- package/public/assets/tabs-CYoB-S2t.js.br +0 -0
- package/public/assets/tabs-CYoB-S2t.js.gz +0 -0
- package/public/assets/{tech-label-BMvuJmUf.js → tech-label-fz5PvoAL.js} +1 -1
- package/public/assets/tech-label-fz5PvoAL.js.br +3 -0
- package/public/assets/tech-label-fz5PvoAL.js.gz +0 -0
- package/public/assets/{trash-Cwj2dbgB.js → trash-CZ-V9h2G.js} +3 -3
- package/public/assets/trash-CZ-V9h2G.js.br +0 -0
- package/public/assets/trash-CZ-V9h2G.js.gz +0 -0
- package/public/assets/{video-swsaHrfi.js → video-CilstoqV.js} +2 -2
- package/public/assets/video-CilstoqV.js.br +0 -0
- package/public/assets/video-CilstoqV.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/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.br +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,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 {
|
|
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
|
-
|
|
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.
|
|
1550
|
-
this.forgetSessionState(msg.sessionId);
|
|
1611
|
+
this.removeSession(msg.sessionId);
|
|
1551
1612
|
}
|
|
1552
1613
|
break;
|
|
1553
1614
|
}
|
|
1554
1615
|
case 'session:leave': {
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
2417
|
-
if (
|
|
2418
|
-
const
|
|
2419
|
-
if (
|
|
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
|
}
|