@xenon-device-management/xenon 1.6.0 → 1.7.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/lib/package.json +1 -1
- package/lib/src/XenonCapabilityManager.js +15 -4
- package/lib/src/app/index.js +6 -0
- package/lib/src/app/routers/ports.js +63 -0
- package/lib/src/app/routers/reservation.js +11 -0
- package/lib/src/app/routers/sdk-leases.js +113 -0
- package/lib/src/app/routers/sdk-version.js +18 -0
- package/lib/src/data-service/device-store.js +8 -1
- package/lib/src/data-service/prisma-store.js +10 -1
- package/lib/src/device-utils.js +24 -1
- package/lib/src/generated/client/edge.js +24 -3
- package/lib/src/generated/client/index-browser.js +21 -0
- package/lib/src/generated/client/index.d.ts +1787 -286
- package/lib/src/generated/client/index.js +24 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +27 -1
- package/lib/src/generated/client/wasm.js +21 -0
- package/lib/src/middleware/leaseTokenMiddleware.js +17 -0
- package/lib/src/prisma.js +1 -1
- package/lib/src/services/ServerManager.js +10 -0
- package/lib/src/services/lease/LeaseOrphanSweeper.js +64 -0
- package/lib/src/services/lease/LeaseService.js +261 -0
- package/lib/src/services/lease/buildCapabilityBag.js +33 -0
- package/lib/src/services/lease/leaseToken.js +25 -0
- package/lib/src/services/ports/PortAllocatorClient.js +61 -0
- package/lib/src/services/ports/PortAllocatorService.js +60 -0
- package/lib/test/integration/{testHelpers.js → legacy-deprecation-headers.spec.js} +23 -29
- package/lib/test/integration/ports-router.spec.js +85 -0
- package/lib/test/integration/sdk-leases-router.spec.js +126 -0
- package/lib/test/integration/sdk-version.spec.js +69 -0
- package/lib/test/unit/findAndLockDevice-leasemerge.spec.js +99 -0
- package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +86 -0
- package/lib/test/unit/lease/LeaseService.spec.js +261 -0
- package/lib/test/unit/lease/buildCapabilityBag.spec.js +38 -0
- package/lib/test/unit/lease/leaseToken.spec.js +26 -0
- package/lib/test/unit/ports/PortAllocatorService.spec.js +101 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260514033625_add_lease_model/migration.sql +53 -0
- package/prisma/schema.prisma +26 -0
package/lib/package.json
CHANGED
|
@@ -101,8 +101,15 @@ function androidCapabilities(caps, freeDevice) {
|
|
|
101
101
|
const fm = ensureFirstMatch(caps);
|
|
102
102
|
fm['appium:udid'] = freeDevice.udid;
|
|
103
103
|
fm['platformName'] = freeDevice.platform;
|
|
104
|
-
fm['appium:systemPort']
|
|
105
|
-
|
|
104
|
+
if (!fm['appium:systemPort']) {
|
|
105
|
+
fm['appium:systemPort'] = yield (0, get_port_1.default)();
|
|
106
|
+
}
|
|
107
|
+
// Lease bag emits lowercase 'chromedriverPort' (W3C-canonical); existing
|
|
108
|
+
// non-lease path uses capital 'chromeDriverPort'. Recognize both spellings
|
|
109
|
+
// so a lease-provided lowercase value is not duplicated.
|
|
110
|
+
if (!fm['appium:chromedriverPort'] && !fm['appium:chromeDriverPort']) {
|
|
111
|
+
fm['appium:chromeDriverPort'] = yield (0, get_port_1.default)();
|
|
112
|
+
}
|
|
106
113
|
fm['appium:adbRemoteHost'] = freeDevice.adbRemoteHost;
|
|
107
114
|
fm['appium:adbPort'] = freeDevice.adbPort;
|
|
108
115
|
if (freeDevice.chromeDriverPath)
|
|
@@ -125,8 +132,12 @@ function iOSCapabilities(caps, freeDevice) {
|
|
|
125
132
|
fm['platformName'] = freeDevice.platform;
|
|
126
133
|
fm['appium:deviceName'] = freeDevice.name;
|
|
127
134
|
fm['appium:platformVersion'] = freeDevice.sdk;
|
|
128
|
-
fm['appium:wdaLocalPort']
|
|
129
|
-
|
|
135
|
+
if (!fm['appium:wdaLocalPort']) {
|
|
136
|
+
fm['appium:wdaLocalPort'] = freeDevice.wdaLocalPort;
|
|
137
|
+
}
|
|
138
|
+
if (!fm['appium:mjpegServerPort']) {
|
|
139
|
+
fm['appium:mjpegServerPort'] = freeDevice.mjpegServerPort;
|
|
140
|
+
}
|
|
130
141
|
fm['appium:derivedDataPath'] = freeDevice.derivedDataPath;
|
|
131
142
|
// Technical Optimization: Reuse existing WDA tunnel if Stream Service is active
|
|
132
143
|
// This prevents "Port Occupied" errors when the dashboard is open and speeds up startup by 15-30s
|
package/lib/src/app/index.js
CHANGED
|
@@ -64,6 +64,9 @@ const control_1 = __importDefault(require("./routers/control"));
|
|
|
64
64
|
const apps_1 = __importDefault(require("./routers/apps"));
|
|
65
65
|
const webhook_1 = __importDefault(require("./routers/webhook"));
|
|
66
66
|
const reservation_1 = __importDefault(require("./routers/reservation"));
|
|
67
|
+
const ports_1 = __importDefault(require("./routers/ports"));
|
|
68
|
+
const sdk_leases_1 = __importDefault(require("./routers/sdk-leases"));
|
|
69
|
+
const sdk_version_1 = __importDefault(require("./routers/sdk-version"));
|
|
67
70
|
const config_2 = __importDefault(require("./routers/config"));
|
|
68
71
|
const interceptor_1 = __importDefault(require("./routers/interceptor"));
|
|
69
72
|
const bug_report_1 = __importDefault(require("./routers/bug-report"));
|
|
@@ -261,6 +264,9 @@ function createRouter(pluginArgs) {
|
|
|
261
264
|
bug_report_1.default.register(apiRouter);
|
|
262
265
|
recordings_1.default.register(apiRouter);
|
|
263
266
|
apiRouter.use('/reservation', reservation_1.default);
|
|
267
|
+
(0, ports_1.default)(apiRouter);
|
|
268
|
+
(0, sdk_leases_1.default)(apiRouter);
|
|
269
|
+
(0, sdk_version_1.default)(apiRouter);
|
|
264
270
|
// Principal Health: Add ping endpoint
|
|
265
271
|
apiRouter.get('/ping', (req, res) => res.json({ pong: true, version: package_json_1.default.version }));
|
|
266
272
|
// Setup Swagger API documentation at /xenon/api-docs
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.makeRouter = makeRouter;
|
|
16
|
+
exports.default = register;
|
|
17
|
+
const express_1 = require("express");
|
|
18
|
+
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
19
|
+
const scopeGuard_1 = require("../../middleware/scopeGuard");
|
|
20
|
+
const logger_1 = __importDefault(require("../../logger"));
|
|
21
|
+
const PortAllocatorService_1 = require("../../services/ports/PortAllocatorService");
|
|
22
|
+
const VALID_PURPOSES = ['systemPort', 'wdaLocalPort', 'chromedriverPort', 'mjpegServerPort'];
|
|
23
|
+
/**
|
|
24
|
+
* Factory that returns the ports router. Accepts dependency injection of
|
|
25
|
+
* the allocator for tests; defaults to a fresh PortAllocatorService.
|
|
26
|
+
*/
|
|
27
|
+
function makeRouter(opts = {}) {
|
|
28
|
+
var _a;
|
|
29
|
+
const router = (0, express_1.Router)();
|
|
30
|
+
const allocator = (_a = opts.allocator) !== null && _a !== void 0 ? _a : new PortAllocatorService_1.PortAllocatorService();
|
|
31
|
+
const logger = logger_1.default.scope('ports-router');
|
|
32
|
+
router.post('/allocate', (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
33
|
+
var _a;
|
|
34
|
+
const { udid, host, purposes, durationMs, leaseId } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
35
|
+
if (typeof udid !== 'string' ||
|
|
36
|
+
typeof host !== 'string' ||
|
|
37
|
+
!Array.isArray(purposes) ||
|
|
38
|
+
purposes.length === 0 ||
|
|
39
|
+
purposes.some((p) => !VALID_PURPOSES.includes(p))) {
|
|
40
|
+
return res.status(400).json({ error: 'bad_request', details: 'udid, host, purposes (non-empty, valid) required' });
|
|
41
|
+
}
|
|
42
|
+
const dur = typeof durationMs === 'number' && durationMs > 0 ? durationMs : 30 * 60 * 1000;
|
|
43
|
+
try {
|
|
44
|
+
const ports = yield allocator.allocate({ udid, host, purposes, durationMs: dur, leaseId });
|
|
45
|
+
return res.status(200).json({ ports });
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
logger.error(`allocate failed: ${err.message}`);
|
|
49
|
+
return res.status(500).json({ error: 'allocate_failed', details: err.message });
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
return router;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Default-export router with auth gates applied. Used by app/index.ts.
|
|
56
|
+
*/
|
|
57
|
+
function register(apiRouter) {
|
|
58
|
+
const router = (0, express_1.Router)();
|
|
59
|
+
router.use((0, roleGuard_1.roleGuard)('ADMIN'));
|
|
60
|
+
router.use((0, scopeGuard_1.scopeGuard)(['devices']));
|
|
61
|
+
router.use(makeRouter());
|
|
62
|
+
apiRouter.use('/ports', router);
|
|
63
|
+
}
|
|
@@ -18,6 +18,17 @@ const logger_1 = __importDefault(require("../../logger"));
|
|
|
18
18
|
const scopeGuard_1 = require("../../middleware/scopeGuard");
|
|
19
19
|
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
20
20
|
const router = express_1.default.Router();
|
|
21
|
+
// Phase 2: legacy /reservation endpoints are deprecated in favor of
|
|
22
|
+
// /xenon/api/sdk/leases. Per RFC 8594, advertise the deprecation + sunset
|
|
23
|
+
// date + link to the migration path on every response.
|
|
24
|
+
const SUNSET_DATE = '2027-01-01T00:00:00Z';
|
|
25
|
+
const LEASE_DOC_URL = 'https://github.com/qasecret/xenon/blob/main/docs/superpowers/specs/2026-05-14-server-side-lease-api-design.md';
|
|
26
|
+
router.use((_req, res, next) => {
|
|
27
|
+
res.setHeader('Deprecation', 'true');
|
|
28
|
+
res.setHeader('Sunset', SUNSET_DATE);
|
|
29
|
+
res.setHeader('Link', `<${LEASE_DOC_URL}>; rel="alternate"`);
|
|
30
|
+
next();
|
|
31
|
+
});
|
|
21
32
|
// MEMBER-tier baseline: reserving devices for yourself is a Member-tier operation
|
|
22
33
|
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
23
34
|
// Reserving / releasing / extending a device hold requires devices scope.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.makeRouter = makeRouter;
|
|
16
|
+
exports.default = register;
|
|
17
|
+
const express_1 = require("express");
|
|
18
|
+
const typedi_1 = require("typedi");
|
|
19
|
+
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
20
|
+
const scopeGuard_1 = require("../../middleware/scopeGuard");
|
|
21
|
+
const leaseTokenMiddleware_1 = require("../../middleware/leaseTokenMiddleware");
|
|
22
|
+
const LeaseService_1 = require("../../services/lease/LeaseService");
|
|
23
|
+
const logger_1 = __importDefault(require("../../logger"));
|
|
24
|
+
function makeRouter(opts = {}) {
|
|
25
|
+
var _a;
|
|
26
|
+
const router = (0, express_1.Router)();
|
|
27
|
+
const svc = (_a = opts.leaseService) !== null && _a !== void 0 ? _a : typedi_1.Container.get(LeaseService_1.LeaseService);
|
|
28
|
+
const logger = logger_1.default.scope('sdk-leases-router');
|
|
29
|
+
router.post('/', (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
30
|
+
var _a, _b, _c;
|
|
31
|
+
const { filters, durationMs, heartbeatSeconds, reason, buildId } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
32
|
+
if (!filters || typeof filters.platform !== 'string') {
|
|
33
|
+
return res.status(400).json({ error: 'bad_request', details: 'filters.platform required' });
|
|
34
|
+
}
|
|
35
|
+
const apiKey = req.apiKey;
|
|
36
|
+
try {
|
|
37
|
+
const out = yield svc.create({
|
|
38
|
+
filters,
|
|
39
|
+
durationMs: typeof durationMs === 'number' ? durationMs : 30 * 60 * 1000,
|
|
40
|
+
heartbeatSeconds: typeof heartbeatSeconds === 'number' ? heartbeatSeconds : 30,
|
|
41
|
+
actorId: (_b = apiKey === null || apiKey === void 0 ? void 0 : apiKey.id) !== null && _b !== void 0 ? _b : 'anonymous',
|
|
42
|
+
teamId: (_c = apiKey === null || apiKey === void 0 ? void 0 : apiKey.teamId) !== null && _c !== void 0 ? _c : null,
|
|
43
|
+
buildId,
|
|
44
|
+
reason,
|
|
45
|
+
});
|
|
46
|
+
return res.status(201).json(out);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err instanceof LeaseService_1.NoMatchingDevice) {
|
|
50
|
+
return res.status(404).json({ error: 'no_matching_device', message: err.message });
|
|
51
|
+
}
|
|
52
|
+
if (err instanceof LeaseService_1.AllMatchingBusy) {
|
|
53
|
+
return res.status(409).json({ error: 'all_matching_busy', retryAfterMs: 2000 });
|
|
54
|
+
}
|
|
55
|
+
if (err instanceof LeaseService_1.DeviceUnhealthy) {
|
|
56
|
+
return res.status(503).json({ error: 'device_unhealthy', details: err.message });
|
|
57
|
+
}
|
|
58
|
+
logger.error(`create failed: ${err.message}`);
|
|
59
|
+
return res.status(500).json({ error: 'internal', message: err.message });
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
router.post('/:id/heartbeat', leaseTokenMiddleware_1.leaseTokenMiddleware, (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
63
|
+
try {
|
|
64
|
+
const out = yield svc.heartbeat(req.params.id, req.leaseToken);
|
|
65
|
+
return res.status(200).json(out);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof LeaseService_1.LeaseTokenMismatch)
|
|
69
|
+
return res.status(403).json({ error: 'token_mismatch' });
|
|
70
|
+
if (err instanceof LeaseService_1.LeaseGone)
|
|
71
|
+
return res.status(410).json({ error: 'gone', message: err.message });
|
|
72
|
+
return res.status(500).json({ error: 'internal', message: err.message });
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
router.post('/:id/extend', leaseTokenMiddleware_1.leaseTokenMiddleware, (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
76
|
+
var _a;
|
|
77
|
+
const { durationMs } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
78
|
+
if (typeof durationMs !== 'number')
|
|
79
|
+
return res.status(400).json({ error: 'bad_request', details: 'durationMs required' });
|
|
80
|
+
try {
|
|
81
|
+
const out = yield svc.extend(req.params.id, req.leaseToken, durationMs);
|
|
82
|
+
return res.status(200).json(out);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (err instanceof LeaseService_1.LeaseTokenMismatch)
|
|
86
|
+
return res.status(403).json({ error: 'token_mismatch' });
|
|
87
|
+
if (err instanceof LeaseService_1.LeaseGone)
|
|
88
|
+
return res.status(410).json({ error: 'gone' });
|
|
89
|
+
return res.status(500).json({ error: 'internal', message: err.message });
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
router.delete('/:id', leaseTokenMiddleware_1.leaseTokenMiddleware, (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
93
|
+
try {
|
|
94
|
+
yield svc.release(req.params.id, req.leaseToken);
|
|
95
|
+
return res.status(204).send();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err instanceof LeaseService_1.LeaseTokenMismatch)
|
|
99
|
+
return res.status(403).json({ error: 'token_mismatch' });
|
|
100
|
+
if (err instanceof LeaseService_1.LeaseGone)
|
|
101
|
+
return res.status(404).json({ error: 'not_found' });
|
|
102
|
+
return res.status(500).json({ error: 'internal', message: err.message });
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
return router;
|
|
106
|
+
}
|
|
107
|
+
function register(apiRouter) {
|
|
108
|
+
const router = (0, express_1.Router)();
|
|
109
|
+
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
110
|
+
router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
|
|
111
|
+
router.use(makeRouter());
|
|
112
|
+
apiRouter.use('/sdk/leases', router);
|
|
113
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = register;
|
|
7
|
+
const express_1 = require("express");
|
|
8
|
+
const package_json_1 = __importDefault(require("../../../package.json"));
|
|
9
|
+
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
10
|
+
const SUPPORTS = ['leases', 'ports', 'heartbeat'];
|
|
11
|
+
function register(apiRouter) {
|
|
12
|
+
const router = (0, express_1.Router)();
|
|
13
|
+
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
14
|
+
router.get('/', (_req, res) => {
|
|
15
|
+
res.json({ pluginVersion: package_json_1.default.version, supports: [...SUPPORTS] });
|
|
16
|
+
});
|
|
17
|
+
apiRouter.use('/sdk/version', router);
|
|
18
|
+
}
|
|
@@ -18,6 +18,7 @@ const semver_1 = __importDefault(require("semver"));
|
|
|
18
18
|
const db_1 = require("./db");
|
|
19
19
|
const prisma_store_1 = require("./prisma-store");
|
|
20
20
|
const config_1 = require("../config");
|
|
21
|
+
const prisma_1 = require("../prisma");
|
|
21
22
|
/**
|
|
22
23
|
* LokiJS Implementation of Device Store (Legacy/Internal)
|
|
23
24
|
*/
|
|
@@ -206,8 +207,14 @@ class LokiDeviceStore {
|
|
|
206
207
|
}
|
|
207
208
|
findAndLockDevice(filterOptions) {
|
|
208
209
|
return __awaiter(this, void 0, void 0, function* () {
|
|
210
|
+
// Phase 2: exclude devices held by an active lease.
|
|
211
|
+
const activeLeases = yield prisma_1.prisma.lease.findMany({
|
|
212
|
+
where: { status: 'active' },
|
|
213
|
+
select: { deviceUdid: true, deviceHost: true },
|
|
214
|
+
});
|
|
215
|
+
const blockedKeys = new Set(activeLeases.map((l) => `${l.deviceUdid}@${l.deviceHost}`));
|
|
209
216
|
const devices = yield this.getDevices(filterOptions);
|
|
210
|
-
const available = devices.find((d) => !d.busy && !d.userBlocked);
|
|
217
|
+
const available = devices.find((d) => !d.busy && !d.userBlocked && !blockedKeys.has(`${d.udid}@${d.host}`));
|
|
211
218
|
if (available) {
|
|
212
219
|
yield this.updateDevice(available.udid, available.host, { busy: true });
|
|
213
220
|
return available;
|
|
@@ -262,10 +262,19 @@ class PrismaDeviceStore {
|
|
|
262
262
|
}
|
|
263
263
|
findAndLockDevice(filterOptions) {
|
|
264
264
|
return __awaiter(this, void 0, void 0, function* () {
|
|
265
|
+
// Phase 2: exclude devices held by an active lease.
|
|
266
|
+
const activeLeases = yield this.prisma.lease.findMany({
|
|
267
|
+
where: { status: 'active' },
|
|
268
|
+
select: { deviceUdid: true, deviceHost: true },
|
|
269
|
+
});
|
|
270
|
+
const blockedKeys = new Set(activeLeases.map((l) => `${l.deviceUdid}@${l.deviceHost}`));
|
|
265
271
|
// 1. Get candidate devices that match the filters and are NOT busy
|
|
266
272
|
const candidates = yield this.getDevices(Object.assign(Object.assign({}, filterOptions), { busy: false }));
|
|
267
|
-
// 2. Attempt to atomically lock them one by one
|
|
273
|
+
// 2. Attempt to atomically lock them one by one, skipping lease-blocked devices
|
|
268
274
|
for (const device of candidates) {
|
|
275
|
+
if (blockedKeys.has(`${device.udid}@${device.host}`)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
269
278
|
const result = yield this.prisma.device.updateMany({
|
|
270
279
|
where: {
|
|
271
280
|
udid: device.udid,
|
package/lib/src/device-utils.js
CHANGED
|
@@ -153,8 +153,31 @@ function isDeviceConfigPathAbsolute(path) {
|
|
|
153
153
|
*/
|
|
154
154
|
function allocateDeviceForSession(capability, deviceTimeOutMs, deviceQueryIntervalMs, pluginArgs, callerTeamIds) {
|
|
155
155
|
return __awaiter(this, void 0, void 0, function* () {
|
|
156
|
-
var _a, _b;
|
|
156
|
+
var _a, _b, _c;
|
|
157
157
|
const firstMatch = Object.assign({}, (_b = (_a = capability.firstMatch) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : {}, capability.alwaysMatch);
|
|
158
|
+
// Lease-bound session: SDK acquired a lease via /xenon/api/sdk/leases and
|
|
159
|
+
// passes its id through caps. Resolve directly; skip findAndLockDevice
|
|
160
|
+
// (device is already locked) and skip port allocation in XenonCapabilityManager
|
|
161
|
+
// (ports are already in firstMatch).
|
|
162
|
+
const xenonOpts = ((_c = firstMatch['xenon:options']) !== null && _c !== void 0 ? _c : {});
|
|
163
|
+
const leaseIdCap = typeof xenonOpts.leaseId === 'string' ? xenonOpts.leaseId : undefined;
|
|
164
|
+
if (leaseIdCap) {
|
|
165
|
+
const { LeaseService } = yield Promise.resolve().then(() => __importStar(require('./services/lease/LeaseService')));
|
|
166
|
+
const { Container } = yield Promise.resolve().then(() => __importStar(require('typedi')));
|
|
167
|
+
const resolved = yield Container.get(LeaseService).resolve(leaseIdCap);
|
|
168
|
+
if (!resolved) {
|
|
169
|
+
throw new Error(`lease ${leaseIdCap} is not active`);
|
|
170
|
+
}
|
|
171
|
+
const leaseStore = device_store_1.DeviceStoreFactory.getStore();
|
|
172
|
+
const leasedDevice = yield leaseStore.findDevice({
|
|
173
|
+
udid: resolved.deviceUdid,
|
|
174
|
+
host: resolved.deviceHost,
|
|
175
|
+
});
|
|
176
|
+
if (!leasedDevice) {
|
|
177
|
+
throw new Error(`lease ${leaseIdCap} references missing device ${resolved.deviceUdid}@${resolved.deviceHost}`);
|
|
178
|
+
}
|
|
179
|
+
return leasedDevice;
|
|
180
|
+
}
|
|
158
181
|
const filters = getDeviceFiltersFromCapability(firstMatch, pluginArgs);
|
|
159
182
|
// callerTeamIds === undefined → unscoped (admin / auth-disabled / back-compat).
|
|
160
183
|
// callerTeamIds === [] → caller has no team, only shared pool visible.
|