@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.
Files changed (40) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/src/XenonCapabilityManager.js +15 -4
  3. package/lib/src/app/index.js +6 -0
  4. package/lib/src/app/routers/ports.js +63 -0
  5. package/lib/src/app/routers/reservation.js +11 -0
  6. package/lib/src/app/routers/sdk-leases.js +113 -0
  7. package/lib/src/app/routers/sdk-version.js +18 -0
  8. package/lib/src/data-service/device-store.js +8 -1
  9. package/lib/src/data-service/prisma-store.js +10 -1
  10. package/lib/src/device-utils.js +24 -1
  11. package/lib/src/generated/client/edge.js +24 -3
  12. package/lib/src/generated/client/index-browser.js +21 -0
  13. package/lib/src/generated/client/index.d.ts +1787 -286
  14. package/lib/src/generated/client/index.js +24 -3
  15. package/lib/src/generated/client/package.json +1 -1
  16. package/lib/src/generated/client/schema.prisma +27 -1
  17. package/lib/src/generated/client/wasm.js +21 -0
  18. package/lib/src/middleware/leaseTokenMiddleware.js +17 -0
  19. package/lib/src/prisma.js +1 -1
  20. package/lib/src/services/ServerManager.js +10 -0
  21. package/lib/src/services/lease/LeaseOrphanSweeper.js +64 -0
  22. package/lib/src/services/lease/LeaseService.js +261 -0
  23. package/lib/src/services/lease/buildCapabilityBag.js +33 -0
  24. package/lib/src/services/lease/leaseToken.js +25 -0
  25. package/lib/src/services/ports/PortAllocatorClient.js +61 -0
  26. package/lib/src/services/ports/PortAllocatorService.js +60 -0
  27. package/lib/test/integration/{testHelpers.js → legacy-deprecation-headers.spec.js} +23 -29
  28. package/lib/test/integration/ports-router.spec.js +85 -0
  29. package/lib/test/integration/sdk-leases-router.spec.js +126 -0
  30. package/lib/test/integration/sdk-version.spec.js +69 -0
  31. package/lib/test/unit/findAndLockDevice-leasemerge.spec.js +99 -0
  32. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +86 -0
  33. package/lib/test/unit/lease/LeaseService.spec.js +261 -0
  34. package/lib/test/unit/lease/buildCapabilityBag.spec.js +38 -0
  35. package/lib/test/unit/lease/leaseToken.spec.js +26 -0
  36. package/lib/test/unit/ports/PortAllocatorService.spec.js +101 -0
  37. package/lib/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +1 -1
  39. package/prisma/migrations/20260514033625_add_lease_model/migration.sql +53 -0
  40. package/prisma/schema.prisma +26 -0
package/lib/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {
@@ -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'] = yield (0, get_port_1.default)();
105
- fm['appium:chromeDriverPort'] = yield (0, get_port_1.default)();
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'] = freeDevice.wdaLocalPort;
129
- fm['appium:mjpegServerPort'] = freeDevice.mjpegServerPort;
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
@@ -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,
@@ -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.