@opengeni/api-router 0.5.3 → 0.5.5
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/app.d.ts +9 -1
- package/dist/app.js +7 -1
- package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
- package/dist/chunk-HBEJMWD3.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +279 -55
- package/dist/index.js.map +1 -1
- package/package.json +20 -20
- package/src/app.ts +583 -166
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +84 -8
- package/src/index.ts +178 -75
- package/src/integrations/oauth-client.ts +403 -120
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1600 -693
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +71 -33
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +870 -328
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +146 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- package/dist/chunk-3HIA43CC.js.map +0 -1
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
// IMPORT DISCIPLINE: sandbox symbols come ONLY from @opengeni/runtime/sandbox
|
|
18
18
|
// (the agent-loop-free leaf) — enforced by sandbox-access-import-guard.test.ts.
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
applyGitAuthPointerEnvironment,
|
|
22
|
+
hasGitCredentialRepositorySelection,
|
|
23
|
+
hasGitHubRepositorySelection,
|
|
24
|
+
stableSandboxEnvironmentForRun,
|
|
25
|
+
type Settings,
|
|
26
|
+
} from "@opengeni/config";
|
|
21
27
|
import { githubAppBotIdentity } from "@opengeni/github";
|
|
22
28
|
import type { Session } from "@opengeni/contracts";
|
|
23
29
|
import {
|
|
@@ -26,9 +32,9 @@ import {
|
|
|
26
32
|
failWarmingToCold,
|
|
27
33
|
getSandboxSessionEnvelope,
|
|
28
34
|
loadWorkspaceEnvironmentForRun,
|
|
35
|
+
markWarmLeaseInstanceLost,
|
|
29
36
|
readLease,
|
|
30
37
|
releaseLeaseHolder,
|
|
31
|
-
SandboxLeaseSupersededError,
|
|
32
38
|
type Database,
|
|
33
39
|
type LeaseSnapshot,
|
|
34
40
|
} from "@opengeni/db";
|
|
@@ -37,6 +43,8 @@ import { HTTPException } from "hono/http-exception";
|
|
|
37
43
|
|
|
38
44
|
import {
|
|
39
45
|
establishSandboxSessionFromEnvelope,
|
|
46
|
+
isProviderSandboxNotFoundError,
|
|
47
|
+
SandboxResumeStateUnavailableError,
|
|
40
48
|
serializeEstablishedSandboxEnvelope,
|
|
41
49
|
SandboxChannelAService,
|
|
42
50
|
ChannelAConflictError,
|
|
@@ -84,7 +92,7 @@ export async function withChannelA<T>(
|
|
|
84
92
|
fn: (handle: ChannelAHandle) => Promise<T>,
|
|
85
93
|
): Promise<T> {
|
|
86
94
|
const { db, settings, bus } = services;
|
|
87
|
-
const { accountId, workspaceId, session
|
|
95
|
+
const { accountId, workspaceId, session } = ctx;
|
|
88
96
|
|
|
89
97
|
if (session.sandboxBackend === "none") {
|
|
90
98
|
throw new HTTPException(409, { message: "sandbox not available" });
|
|
@@ -116,11 +124,14 @@ export async function withChannelA<T>(
|
|
|
116
124
|
backend: session.sandboxBackend,
|
|
117
125
|
os: session.sandboxOs,
|
|
118
126
|
leaseTtlMs,
|
|
127
|
+
warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
|
|
119
128
|
});
|
|
120
129
|
|
|
121
130
|
if (acquired.role === "fenced") {
|
|
122
131
|
await release();
|
|
123
|
-
throw new HTTPException(409, {
|
|
132
|
+
throw new HTTPException(409, {
|
|
133
|
+
message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry`,
|
|
134
|
+
});
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
let established: EstablishedSandboxSession | undefined;
|
|
@@ -136,13 +147,26 @@ export async function withChannelA<T>(
|
|
|
136
147
|
// the SESSION's backend (the establish below passes backendOverride:
|
|
137
148
|
// session.sandboxBackend, and HOME/token-file/askpass are backend-derived),
|
|
138
149
|
// NOT the deployment default — mirrors sessionAttachEnvironment.
|
|
139
|
-
const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
|
|
151
|
+
db,
|
|
152
|
+
settings,
|
|
153
|
+
workspaceId,
|
|
154
|
+
session.environmentId,
|
|
155
|
+
);
|
|
156
|
+
const settingsForSession =
|
|
157
|
+
session.sandboxBackend !== settings.sandboxBackend
|
|
158
|
+
? { ...settings, sandboxBackend: session.sandboxBackend }
|
|
159
|
+
: settings;
|
|
160
|
+
const environment = stableSandboxEnvironmentForRun(
|
|
161
|
+
settingsForSession,
|
|
162
|
+
workspaceEnvironment?.values ?? {},
|
|
163
|
+
{ workspaceId },
|
|
164
|
+
);
|
|
165
|
+
if (hasGitCredentialRepositorySelection(session.resources)) {
|
|
166
|
+
applyGitAuthPointerEnvironment(
|
|
167
|
+
environment,
|
|
168
|
+
hasGitHubRepositorySelection(session.resources) ? githubAppBotIdentity(settings) : null,
|
|
169
|
+
);
|
|
146
170
|
}
|
|
147
171
|
|
|
148
172
|
if (acquired.role === "spawner") {
|
|
@@ -161,18 +185,22 @@ export async function withChannelA<T>(
|
|
|
161
185
|
try {
|
|
162
186
|
established = await establishSandboxSessionFromEnvelope(settings, spawnEnvelope, {
|
|
163
187
|
sessionId: session.id,
|
|
188
|
+
recovery: "create-or-restore",
|
|
164
189
|
backendOverride: session.sandboxBackend,
|
|
165
190
|
environment,
|
|
166
191
|
});
|
|
167
192
|
} catch (error) {
|
|
168
193
|
await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
|
|
169
|
-
throw new HTTPException(409, {
|
|
194
|
+
throw new HTTPException(409, {
|
|
195
|
+
message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
|
|
196
|
+
});
|
|
170
197
|
}
|
|
171
198
|
// Persist the LIVE box as the lease's resume_state so the NEXT op resumes
|
|
172
199
|
// this box by id rather than cold-creating a rival (the box-churn the
|
|
173
200
|
// prove-it surfaced). Fall back to the session envelope when serialize is
|
|
174
201
|
// unavailable.
|
|
175
|
-
const resumeEnvelope =
|
|
202
|
+
const resumeEnvelope =
|
|
203
|
+
(await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
|
|
176
204
|
const committed = await commitWarmingToWarm(db, {
|
|
177
205
|
accountId,
|
|
178
206
|
workspaceId,
|
|
@@ -185,20 +213,59 @@ export async function withChannelA<T>(
|
|
|
185
213
|
leaseTtlMs,
|
|
186
214
|
});
|
|
187
215
|
if (!committed.committed || !committed.lease) {
|
|
188
|
-
throw new HTTPException(409, {
|
|
216
|
+
throw new HTTPException(409, {
|
|
217
|
+
message: `sandbox lease superseded (epoch ${expectedEpoch}); retry`,
|
|
218
|
+
});
|
|
189
219
|
}
|
|
190
220
|
leaseSnapshot = committed.lease;
|
|
191
221
|
} else {
|
|
192
222
|
// ATTACHED / REARMED: the box is live. Read the lease to get the
|
|
193
223
|
// authoritative resume_state, then resume by id for this op.
|
|
194
224
|
const live = await readLease(db, workspaceId, sandboxGroupId);
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
225
|
+
if (
|
|
226
|
+
!live ||
|
|
227
|
+
live.liveness !== "warm" ||
|
|
228
|
+
live.leaseEpoch !== acquired.lease.leaseEpoch ||
|
|
229
|
+
live.instanceId === null
|
|
230
|
+
) {
|
|
231
|
+
throw new HTTPException(409, {
|
|
232
|
+
message: `sandbox lease is not attachable; retry`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
leaseSnapshot = live;
|
|
236
|
+
try {
|
|
237
|
+
established = await establishSandboxSessionFromEnvelope(settings, live.resumeState, {
|
|
238
|
+
sessionId: session.id,
|
|
239
|
+
recovery: "resume-only",
|
|
240
|
+
backendOverride: session.sandboxBackend,
|
|
241
|
+
environment,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (
|
|
245
|
+
!(error instanceof SandboxResumeStateUnavailableError) &&
|
|
246
|
+
!isProviderSandboxNotFoundError(session.sandboxBackend, error)
|
|
247
|
+
) {
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
const marked = await markWarmLeaseInstanceLost(db, {
|
|
251
|
+
accountId,
|
|
252
|
+
workspaceId,
|
|
253
|
+
sandboxGroupId,
|
|
254
|
+
expectedEpoch: live.leaseEpoch,
|
|
255
|
+
expectedInstanceId: live.instanceId,
|
|
256
|
+
});
|
|
257
|
+
if (marked.status === "marked") {
|
|
258
|
+
await appendAndPublishEvents(db, bus, workspaceId, session.id, [
|
|
259
|
+
{
|
|
260
|
+
type: "sandbox.box.lost",
|
|
261
|
+
payload: { sandboxId: live.instanceId },
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
}
|
|
265
|
+
throw new HTTPException(409, {
|
|
266
|
+
message: `sandbox instance was lost; retry to restore it`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
202
269
|
}
|
|
203
270
|
|
|
204
271
|
const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
|
|
@@ -220,7 +287,11 @@ export async function withChannelA<T>(
|
|
|
220
287
|
// default, or a swapped-to selfhosted machine). With the flag off the
|
|
221
288
|
// established group session is used unchanged.
|
|
222
289
|
const routedSession = routingEnabled(settings)
|
|
223
|
-
? wrapChannelABoxWithRouting(
|
|
290
|
+
? wrapChannelABoxWithRouting(
|
|
291
|
+
{ db, settings, bus },
|
|
292
|
+
{ workspaceId, sessionId: session.id },
|
|
293
|
+
established,
|
|
294
|
+
).session
|
|
224
295
|
: established.session;
|
|
225
296
|
|
|
226
297
|
const service = new SandboxChannelAService({
|
|
@@ -242,10 +313,14 @@ export async function withChannelA<T>(
|
|
|
242
313
|
* already-HTTPException unchanged. */
|
|
243
314
|
export function mapChannelAError(error: unknown): unknown {
|
|
244
315
|
if (error instanceof HTTPException) return error;
|
|
245
|
-
if (error instanceof ChannelAValidationError)
|
|
246
|
-
|
|
247
|
-
if (error instanceof
|
|
248
|
-
|
|
316
|
+
if (error instanceof ChannelAValidationError)
|
|
317
|
+
return new HTTPException(400, { message: error.message });
|
|
318
|
+
if (error instanceof ChannelANotFoundError)
|
|
319
|
+
return new HTTPException(404, { message: error.message });
|
|
320
|
+
if (error instanceof ChannelAConflictError)
|
|
321
|
+
return new HTTPException(409, { message: error.message });
|
|
322
|
+
if (error instanceof ChannelAUnsupportedError)
|
|
323
|
+
return new HTTPException(409, { message: error.message });
|
|
249
324
|
return error;
|
|
250
325
|
}
|
|
251
326
|
|
|
@@ -258,7 +333,9 @@ export function mapChannelAError(error: unknown): unknown {
|
|
|
258
333
|
// destroyed the box mid-flight, so a subsequent fs.read/git/exec hit a different
|
|
259
334
|
// (cold-restored) box and 404'd. We DO NOT close the session; only the reaper
|
|
260
335
|
// (provider stop at refcount 0) terminates a box.
|
|
261
|
-
async function dropEstablishedHandle(
|
|
336
|
+
async function dropEstablishedHandle(
|
|
337
|
+
established: EstablishedSandboxSession | undefined,
|
|
338
|
+
): Promise<void> {
|
|
262
339
|
// No-op beyond dropping the reference: the lease owns lifecycle, the reaper
|
|
263
340
|
// owns teardown. Never session.close()/terminate() a non-owned handle here.
|
|
264
341
|
void established;
|
|
@@ -199,7 +199,11 @@ export async function approveDeviceEnrollment(
|
|
|
199
199
|
},
|
|
200
200
|
): Promise<{ enrollmentId: string; sandboxId: string; allowScreenControl: boolean } | null> {
|
|
201
201
|
const { db } = services;
|
|
202
|
-
const pending = await getPendingDeviceEnrollmentRequestByUserCode(
|
|
202
|
+
const pending = await getPendingDeviceEnrollmentRequestByUserCode(
|
|
203
|
+
db,
|
|
204
|
+
input.workspaceId,
|
|
205
|
+
input.userCode,
|
|
206
|
+
);
|
|
203
207
|
if (!pending) {
|
|
204
208
|
return null;
|
|
205
209
|
}
|
|
@@ -240,7 +244,9 @@ export async function lookupDeviceEnrollment(
|
|
|
240
244
|
|
|
241
245
|
/** Project a resolved pending record to the presentational lookup response (no
|
|
242
246
|
* secrets, no device_code) the approve screen (EnrollmentConsent) renders. */
|
|
243
|
-
export function toLookupResponse(
|
|
247
|
+
export function toLookupResponse(
|
|
248
|
+
record: DeviceEnrollmentRequestRecord,
|
|
249
|
+
): DeviceEnrollmentLookupResponse {
|
|
244
250
|
return {
|
|
245
251
|
workspaceId: record.workspaceId,
|
|
246
252
|
userCode: record.userCode,
|
|
@@ -263,7 +269,11 @@ export async function denyDeviceEnrollment(
|
|
|
263
269
|
input: { accountId: string; workspaceId: string; userCode: string },
|
|
264
270
|
): Promise<{ denied: boolean }> {
|
|
265
271
|
const { db } = services;
|
|
266
|
-
const pending = await getPendingDeviceEnrollmentRequestByUserCode(
|
|
272
|
+
const pending = await getPendingDeviceEnrollmentRequestByUserCode(
|
|
273
|
+
db,
|
|
274
|
+
input.workspaceId,
|
|
275
|
+
input.userCode,
|
|
276
|
+
);
|
|
267
277
|
if (!pending) {
|
|
268
278
|
return { denied: false };
|
|
269
279
|
}
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -31,11 +31,7 @@ import {
|
|
|
31
31
|
type MachineMetricsRow,
|
|
32
32
|
} from "@opengeni/db";
|
|
33
33
|
import type { EventBus } from "@opengeni/events";
|
|
34
|
-
import {
|
|
35
|
-
MachineView,
|
|
36
|
-
MetricSample,
|
|
37
|
-
type MachinesResponse,
|
|
38
|
-
} from "@opengeni/contracts";
|
|
34
|
+
import { MachineView, MetricSample, type MachinesResponse } from "@opengeni/contracts";
|
|
39
35
|
import {
|
|
40
36
|
NatsControlRpc,
|
|
41
37
|
selfhostedLiveness,
|
|
@@ -95,7 +91,11 @@ async function probeEnrollment(
|
|
|
95
91
|
services: MachinesServices,
|
|
96
92
|
workspaceId: string,
|
|
97
93
|
enrollment: EnrollmentRecord,
|
|
98
|
-
): Promise<{
|
|
94
|
+
): Promise<{
|
|
95
|
+
state: "online" | "reconnecting" | "offline";
|
|
96
|
+
consented: boolean;
|
|
97
|
+
hasDisplay: boolean;
|
|
98
|
+
}> {
|
|
99
99
|
const { settings, bus } = services;
|
|
100
100
|
let probeResponded = false;
|
|
101
101
|
if (enrollment.status === "active") {
|
|
@@ -119,6 +119,8 @@ async function probeEnrollment(
|
|
|
119
119
|
allowScreenControl: enrollment.allowScreenControl,
|
|
120
120
|
hasDisplay: enrollment.hasDisplay,
|
|
121
121
|
lastSeenAt: enrollment.lastSeenAt,
|
|
122
|
+
wentOfflineAt: enrollment.wentOfflineAt,
|
|
123
|
+
wentOfflineReason: enrollment.wentOfflineReason,
|
|
122
124
|
},
|
|
123
125
|
probeResponded,
|
|
124
126
|
});
|
|
@@ -186,25 +188,27 @@ export async function listMachines(
|
|
|
186
188
|
// null active pointer routes to. Only present in an in-session view.
|
|
187
189
|
if (session) {
|
|
188
190
|
const groupActive = activeSandboxId === null;
|
|
189
|
-
machines.push(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
191
|
+
machines.push(
|
|
192
|
+
MachineView.parse({
|
|
193
|
+
sandboxId: session.sandboxGroupId,
|
|
194
|
+
enrollmentId: null,
|
|
195
|
+
name: "session sandbox",
|
|
196
|
+
kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
|
|
197
|
+
state: "online",
|
|
198
|
+
active: groupActive,
|
|
199
|
+
isSessionGroup: true,
|
|
200
|
+
// The Modal group box is a cloud Linux box; its precise OS/arch is not
|
|
201
|
+
// surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
|
|
202
|
+
os: "linux",
|
|
203
|
+
arch: "x86_64",
|
|
204
|
+
hasDisplay: false,
|
|
205
|
+
desktopUnavailableReason: null,
|
|
206
|
+
allowScreenControl: false,
|
|
207
|
+
sharedSessionCount: 1,
|
|
208
|
+
lastSeenAt: null,
|
|
209
|
+
metrics: null,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
// The workspace's enrolled selfhosted machines. One bulk metrics read joined
|
|
@@ -216,42 +220,47 @@ export async function listMachines(
|
|
|
216
220
|
]);
|
|
217
221
|
const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
223
|
+
const machineViews = await Promise.all(
|
|
224
|
+
sandboxes.map(async (sandbox): Promise<MachineView | null> => {
|
|
225
|
+
if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
|
|
229
|
+
if (!enrollment) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const [probe, lease] = await Promise.all([
|
|
233
|
+
probeEnrollment(services, workspaceId, enrollment),
|
|
234
|
+
readLease(db, workspaceId, sandbox.id),
|
|
235
|
+
]);
|
|
236
|
+
const state = machineStateFor(probe.state, probe.hasDisplay);
|
|
229
237
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const sharedSessionCount = lease?.refcount ?? 0;
|
|
238
|
+
// sharedSessionCount = the lease refcount for this machine's group. The
|
|
239
|
+
// selfhosted sandbox id IS the lease group key (maxSandboxes:1, N sessions
|
|
240
|
+
// share via refcount). No lease yet → 0 sessions sharing.
|
|
241
|
+
const sharedSessionCount = lease?.refcount ?? 0;
|
|
235
242
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
243
|
+
const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
|
|
244
|
+
return MachineView.parse({
|
|
245
|
+
sandboxId: sandbox.id,
|
|
246
|
+
enrollmentId: enrollment.id,
|
|
247
|
+
name: sandbox.name,
|
|
248
|
+
kind: "selfhosted",
|
|
249
|
+
state,
|
|
250
|
+
active: activeSandboxId === sandbox.id,
|
|
251
|
+
isSessionGroup: false,
|
|
252
|
+
os: enrollment.os,
|
|
253
|
+
arch: enrollment.arch,
|
|
254
|
+
hasDisplay: enrollment.hasDisplay,
|
|
255
|
+
desktopUnavailableReason: enrollment.desktopUnavailableReason,
|
|
256
|
+
allowScreenControl: enrollment.allowScreenControl,
|
|
257
|
+
sharedSessionCount,
|
|
258
|
+
lastSeenAt: enrollment.lastSeenAt,
|
|
259
|
+
metrics: metricsRow ? metricRowToSample(metricsRow) : null,
|
|
260
|
+
});
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
machines.push(...machineViews.filter((machine): machine is MachineView => machine !== null));
|
|
255
264
|
|
|
256
265
|
return { activeSandboxId, activeEpoch, machines };
|
|
257
266
|
}
|