@opengeni/core 0.10.0 → 0.11.2

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.
@@ -17,6 +17,7 @@ import {
17
17
  getSandbox,
18
18
  listSandboxes,
19
19
  readActiveSandbox,
20
+ readLease,
20
21
  requireSession,
21
22
  setActiveSandbox,
22
23
  type Database,
@@ -40,6 +41,15 @@ export type FleetServices = {
40
41
  db: Database;
41
42
  settings: Settings;
42
43
  bus?: EventBus;
44
+ /** API-direct readiness owner for the session's home group. Production wires
45
+ * this to the same viewer/provider verification + rematerialization path; core
46
+ * tests may omit it when exercising pointer mechanics only. */
47
+ ensureSessionGroupReady?: (ctx: FleetContext) => Promise<FleetReadinessHold>;
48
+ };
49
+
50
+ export type FleetReadinessHold = {
51
+ /** Release target liveness only after route publication settles. */
52
+ release: () => Promise<void>;
43
53
  };
44
54
 
45
55
  export type FleetContext = {
@@ -111,6 +121,28 @@ export type FleetSandboxEntry = {
111
121
  /** Selfhosted only: whether a display (real/Xvfb) is present. */
112
122
  hasDisplay?: boolean;
113
123
  lastSeenAt?: string | null;
124
+ /** Orthogonal truth dimensions. `liveness` is only their conservative UI
125
+ * projection and is never evidence for a specific dimension. */
126
+ providerStatus: "not_created" | "creating" | "exists" | "missing" | "unknown";
127
+ leaseLiveness: "cold" | "warming" | "warm" | "draining" | null;
128
+ routeStatus: "attached" | "detached";
129
+ archiveStatus: "none" | "available" | "unverified" | "invalid";
130
+ restoreStatus:
131
+ | "not_required"
132
+ | "pending"
133
+ | "restoring"
134
+ | "verifying"
135
+ | "ready"
136
+ | "degraded"
137
+ | "unrecoverable";
138
+ workspaceStatus: "unknown" | "not_ready" | "ready" | "degraded" | "unrecoverable";
139
+ leaseEpoch: number | null;
140
+ routeEpoch: number;
141
+ /** Numeric/boolean persistence truth only. Archive locations, content hashes,
142
+ * provider identities, and storage handles are intentionally not projected. */
143
+ workspaceGeneration: number | null;
144
+ archiveGeneration: number | null;
145
+ archiveComplete: boolean;
114
146
  };
115
147
 
116
148
  export type FleetListResult = {
@@ -127,7 +159,12 @@ export type FleetSwapResult = {
127
159
  activeSandboxId: string | null;
128
160
  activeEpoch: number;
129
161
  reason?: string;
130
- code?: BackendUnresolvableCode | "concurrent_swap";
162
+ code?:
163
+ | BackendUnresolvableCode
164
+ | "concurrent_swap"
165
+ | "recovery_in_progress"
166
+ | "recovery_degraded"
167
+ | "recovery_unrecoverable";
131
168
  };
132
169
 
133
170
  const PROBE_TIMEOUT_MS = 5_000;
@@ -200,17 +237,42 @@ export async function listFleet(
200
237
  const entries: FleetSandboxEntry[] = [];
201
238
 
202
239
  // The session's own group box (the default/home sandbox; null active pointer ==
203
- // this box). It is live by virtue of being the session's resumable group.
240
+ // this box). A session/group row is not provider existence. Online requires a
241
+ // warm lease, observed provider existence, and verified workspace readiness.
204
242
  const groupActive = pointer.activeSandboxId === null;
243
+ const groupLease = await readLease(db, ctx.workspaceId, ctx.sessionGroupId);
244
+ const groupOnline = Boolean(
245
+ groupLease?.liveness === "warm" &&
246
+ groupLease.recovery.provider.status === "exists" &&
247
+ groupLease.recovery.workspace.status === "ready",
248
+ );
249
+ const groupRecovering = Boolean(
250
+ groupLease &&
251
+ (groupLease.liveness === "warming" ||
252
+ groupLease.recovery.restore.status === "pending" ||
253
+ groupLease.recovery.restore.status === "restoring" ||
254
+ groupLease.recovery.restore.status === "verifying"),
255
+ );
205
256
  entries.push({
206
257
  id: ctx.sessionGroupId,
207
258
  kind: ctx.sessionBackend === "selfhosted" ? "selfhosted" : "modal",
208
259
  name: "session sandbox",
209
- liveness: "online",
260
+ liveness: groupOnline ? "online" : groupRecovering ? "reconnecting" : "offline",
210
261
  active: groupActive,
211
262
  isSessionGroup: true,
212
263
  enrollmentId: null,
213
- attachable: true,
264
+ attachable: groupOnline,
265
+ providerStatus: groupLease?.recovery.provider.status ?? "not_created",
266
+ leaseLiveness: groupLease?.liveness ?? null,
267
+ routeStatus: groupActive ? "attached" : "detached",
268
+ archiveStatus: groupLease?.recovery.archive.status ?? "none",
269
+ restoreStatus: groupLease?.recovery.restore.status ?? "not_required",
270
+ workspaceStatus: groupLease?.recovery.workspace.status ?? "unknown",
271
+ leaseEpoch: groupLease?.leaseEpoch ?? null,
272
+ routeEpoch: pointer.activeEpoch,
273
+ workspaceGeneration: groupLease?.workspaceGeneration ?? null,
274
+ archiveGeneration: groupLease?.archiveGeneration ?? null,
275
+ archiveComplete: groupLease?.archiveComplete ?? false,
214
276
  });
215
277
 
216
278
  // The workspace's first-class selfhosted sandboxes (enrolled machines). Probe
@@ -236,6 +298,22 @@ export async function listFleet(
236
298
  consented: probe.consented,
237
299
  hasDisplay: probe.hasDisplay,
238
300
  lastSeenAt: enrollment?.lastSeenAt ?? null,
301
+ providerStatus:
302
+ probe.liveness === "online"
303
+ ? "exists"
304
+ : probe.liveness === "reconnecting"
305
+ ? "unknown"
306
+ : "missing",
307
+ leaseLiveness: null,
308
+ routeStatus: pointer.activeSandboxId === sandbox.id ? "attached" : "detached",
309
+ archiveStatus: "none",
310
+ restoreStatus: "not_required",
311
+ workspaceStatus: probe.liveness === "online" ? "ready" : "not_ready",
312
+ leaseEpoch: null,
313
+ routeEpoch: pointer.activeEpoch,
314
+ workspaceGeneration: null,
315
+ archiveGeneration: null,
316
+ archiveComplete: false,
239
317
  });
240
318
  }
241
319
 
@@ -345,49 +423,81 @@ export async function swapActiveSandbox(
345
423
  };
346
424
  }
347
425
 
348
- // Read the current epoch, then CAS on it (the fence). One retry on a lost race
349
- // (a concurrent swap bumped the epoch between read and write).
350
- for (let attempt = 0; attempt < 2; attempt += 1) {
351
- const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
352
- activeSandboxId: null,
353
- activeEpoch: 0,
354
- };
355
- // No-op swap (already pointed there) is a success without an epoch bump churn.
356
- if (pointer.activeSandboxId === resolved.targetSandboxId) {
426
+ let readinessHold: FleetReadinessHold | undefined;
427
+ if (resolved.targetSandboxId === null && services.ensureSessionGroupReady) {
428
+ try {
429
+ readinessHold = await services.ensureSessionGroupReady(ctx);
430
+ } catch (error) {
431
+ const lease = await readLease(services.db, ctx.workspaceId, ctx.sessionGroupId);
432
+ const restore = lease?.recovery.restore.status;
433
+ const code =
434
+ restore === "degraded"
435
+ ? ("recovery_degraded" as const)
436
+ : restore === "unrecoverable"
437
+ ? ("recovery_unrecoverable" as const)
438
+ : ("recovery_in_progress" as const);
439
+ const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
440
+ activeSandboxId: null,
441
+ activeEpoch: 0,
442
+ };
357
443
  return {
358
- swapped: true,
444
+ swapped: false,
359
445
  activeSandboxId: pointer.activeSandboxId,
360
446
  activeEpoch: pointer.activeEpoch,
447
+ reason:
448
+ error instanceof Error
449
+ ? error.message
450
+ : "session sandbox did not reach verified readiness",
451
+ code,
361
452
  };
362
453
  }
363
- const result = await setActiveSandbox(services.db, {
364
- accountId: ctx.accountId,
365
- workspaceId: ctx.workspaceId,
366
- sessionId: ctx.sessionId,
367
- targetSandboxId: resolved.targetSandboxId,
368
- expectedEpoch: pointer.activeEpoch,
369
- ...(workingDir !== undefined ? { workingDir } : {}),
370
- });
371
- if (result.swapped && result.pointer) {
372
- return {
373
- swapped: true,
374
- activeSandboxId: result.pointer.activeSandboxId,
375
- activeEpoch: result.pointer.activeEpoch,
454
+ }
455
+
456
+ try {
457
+ // Read the current epoch, then CAS on it (the fence). One retry on a lost race
458
+ // (a concurrent swap bumped the epoch between read and write).
459
+ for (let attempt = 0; attempt < 2; attempt += 1) {
460
+ const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
461
+ activeSandboxId: null,
462
+ activeEpoch: 0,
376
463
  };
464
+ // Even a same-target attach advances active_epoch. It is a repair/fence
465
+ // request, not a no-op acknowledgment: any cached stale route is invalidated
466
+ // only after target readiness has been proved above.
467
+ const result = await setActiveSandbox(services.db, {
468
+ accountId: ctx.accountId,
469
+ workspaceId: ctx.workspaceId,
470
+ sessionId: ctx.sessionId,
471
+ targetSandboxId: resolved.targetSandboxId,
472
+ expectedEpoch: pointer.activeEpoch,
473
+ ...(workingDir !== undefined ? { workingDir } : {}),
474
+ });
475
+ if (result.swapped && result.pointer) {
476
+ return {
477
+ swapped: true,
478
+ activeSandboxId: result.pointer.activeSandboxId,
479
+ activeEpoch: result.pointer.activeEpoch,
480
+ };
481
+ }
482
+ // CAS lost (a concurrent swap won) — re-read + retry once.
377
483
  }
378
- // CAS lost (a concurrent swap won) — re-read + retry once.
484
+ const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
485
+ activeSandboxId: null,
486
+ activeEpoch: 0,
487
+ };
488
+ return {
489
+ swapped: false,
490
+ activeSandboxId: pointer.activeSandboxId,
491
+ activeEpoch: pointer.activeEpoch,
492
+ reason: "a concurrent swap won the epoch fence; re-read and retry",
493
+ code: "concurrent_swap",
494
+ };
495
+ } finally {
496
+ // Do not let a cleanup failure turn an already-committed route CAS into a
497
+ // false failure. Viewer holders are TTL-bounded and will be reaped if this
498
+ // best-effort explicit release cannot complete.
499
+ await readinessHold?.release().catch(() => undefined);
379
500
  }
380
- const pointer = (await readActiveSandbox(services.db, ctx.workspaceId, ctx.sessionId)) ?? {
381
- activeSandboxId: null,
382
- activeEpoch: 0,
383
- };
384
- return {
385
- swapped: false,
386
- activeSandboxId: pointer.activeSandboxId,
387
- activeEpoch: pointer.activeEpoch,
388
- reason: "a concurrent swap won the epoch fence; re-read and retry",
389
- code: "concurrent_swap",
390
- };
391
501
  }
392
502
 
393
503
  export type RunOnOp =
@@ -13,17 +13,33 @@
13
13
  // over the events bus) lives here, not in the leaf (which stays db-free).
14
14
 
15
15
  import type { Settings } from "@opengeni/config";
16
- import { getSandbox, readActiveSandbox, type Database } from "@opengeni/db";
17
- import type { EventBus } from "@opengeni/events";
18
16
  import {
17
+ advanceWorkspaceGenerationForDirectRequest,
18
+ advanceWorkspaceGenerationForRetainedProcess,
19
+ getSandbox,
20
+ markWarmLeaseInstanceLost,
21
+ readActiveSandbox,
22
+ retainWorkspaceMutationProcess,
23
+ settleRetainedProcess,
24
+ verifyDirectWorkspaceMutationSettlement,
25
+ verifyRetainedProcessMutationSettlement,
26
+ type Database,
27
+ type SandboxWorkspaceMutationAdmission,
28
+ } from "@opengeni/db";
29
+ import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
30
+ import {
31
+ isProviderSandboxGoneDuringRoutedOperation,
19
32
  makeActiveBackendResolver,
20
33
  NatsControlRpc,
21
34
  RoutingSandboxSession,
22
35
  type ControlRpc,
23
36
  type EstablishedSandboxSession,
24
37
  type NatsRequestConnection,
38
+ type ResolvedActiveBackend,
25
39
  type RoutableBackendSession,
26
40
  type RoutableSandbox,
41
+ type RoutingRetainedProcess,
42
+ type RoutingRetainedProcessTerminalProof,
27
43
  type SelfhostedRelayConfig,
28
44
  } from "@opengeni/runtime/sandbox";
29
45
 
@@ -97,10 +113,198 @@ export function routingEnabled(settings: Settings): boolean {
97
113
  */
98
114
  export function wrapChannelABoxWithRouting(
99
115
  services: ChannelARoutingServices,
100
- ids: { workspaceId: string; sessionId: string },
116
+ ids: {
117
+ accountId: string;
118
+ workspaceId: string;
119
+ sessionId: string;
120
+ homeLease: {
121
+ sandboxGroupId: string;
122
+ leaseEpoch: number;
123
+ instanceId: string;
124
+ backend: string;
125
+ };
126
+ directRequest: {
127
+ requestId: string;
128
+ holderId: string;
129
+ };
130
+ },
101
131
  established: EstablishedSandboxSession,
102
132
  ): EstablishedSandboxSession {
103
133
  const { db, settings, bus } = services;
134
+ const beforeMutation = async ({
135
+ op,
136
+ backend,
137
+ }: {
138
+ op: string;
139
+ backend: ResolvedActiveBackend;
140
+ }): Promise<SandboxWorkspaceMutationAdmission | null> => {
141
+ // Connected Machines and other non-persistable targets intentionally do
142
+ // not dirty or advance the cloud-home archive generation.
143
+ if (
144
+ backend.sandboxId !== null ||
145
+ backend.leaseEpoch === undefined ||
146
+ backend.providerInstanceId === undefined
147
+ ) {
148
+ return null;
149
+ }
150
+ if (backend.activeEpoch === undefined) {
151
+ throw new Error("API-direct workspace mutation resolved without an active route epoch");
152
+ }
153
+ return await advanceWorkspaceGenerationForDirectRequest(db, {
154
+ accountId: ids.accountId,
155
+ workspaceId: ids.workspaceId,
156
+ sessionId: ids.sessionId,
157
+ requestId: ids.directRequest.requestId,
158
+ holderId: ids.directRequest.holderId,
159
+ sandboxGroupId: ids.homeLease.sandboxGroupId,
160
+ expectedEpoch: backend.leaseEpoch,
161
+ expectedInstanceId: backend.providerInstanceId,
162
+ routeTargetId: backend.sandboxId,
163
+ routeEpoch: backend.activeEpoch,
164
+ operation: op,
165
+ });
166
+ };
167
+ const afterMutation = async ({
168
+ op,
169
+ backend,
170
+ admission,
171
+ outcome,
172
+ retainedProcess,
173
+ }: {
174
+ op: string;
175
+ backend: ResolvedActiveBackend;
176
+ admission: unknown;
177
+ outcome: "resolved" | "rejected";
178
+ result?: unknown;
179
+ retainedProcess?: RoutingRetainedProcess;
180
+ }): Promise<void> => {
181
+ if (admission === null) return;
182
+ if (
183
+ !admission ||
184
+ typeof admission !== "object" ||
185
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
186
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
187
+ "number" ||
188
+ backend.leaseEpoch === undefined ||
189
+ backend.providerInstanceId === undefined ||
190
+ backend.activeEpoch === undefined
191
+ ) {
192
+ throw new Error("API-direct workspace mutation settlement lacked its exact admission");
193
+ }
194
+ const exactAdmission = admission as SandboxWorkspaceMutationAdmission;
195
+ if (outcome === "resolved" && retainedProcess) {
196
+ await retainWorkspaceMutationProcess(db, {
197
+ accountId: ids.accountId,
198
+ workspaceId: ids.workspaceId,
199
+ sessionId: ids.sessionId,
200
+ processId: retainedProcess.id,
201
+ providerSessionId: retainedProcess.providerSessionId,
202
+ admissionId: exactAdmission.id,
203
+ admittedWorkspaceGeneration: exactAdmission.workspaceGeneration,
204
+ operation: op,
205
+ owner: {
206
+ kind: "direct",
207
+ requestId: ids.directRequest.requestId,
208
+ holderId: ids.directRequest.holderId,
209
+ sandboxGroupId: ids.homeLease.sandboxGroupId,
210
+ expectedEpoch: backend.leaseEpoch,
211
+ expectedInstanceId: backend.providerInstanceId,
212
+ routeTargetId: exactAdmission.routeTargetId,
213
+ routeEpoch: exactAdmission.routeEpoch,
214
+ },
215
+ });
216
+ return;
217
+ }
218
+ await verifyDirectWorkspaceMutationSettlement(db, {
219
+ accountId: ids.accountId,
220
+ workspaceId: ids.workspaceId,
221
+ sessionId: ids.sessionId,
222
+ requestId: ids.directRequest.requestId,
223
+ holderId: ids.directRequest.holderId,
224
+ sandboxGroupId: ids.homeLease.sandboxGroupId,
225
+ expectedEpoch: backend.leaseEpoch,
226
+ expectedInstanceId: backend.providerInstanceId,
227
+ routeTargetId: exactAdmission.routeTargetId,
228
+ routeEpoch: exactAdmission.routeEpoch,
229
+ admission: exactAdmission,
230
+ operation: op,
231
+ outcome,
232
+ });
233
+ };
234
+ const beforeProcessMutation = async ({
235
+ op,
236
+ process,
237
+ }: {
238
+ op: string;
239
+ backend: ResolvedActiveBackend;
240
+ process: RoutingRetainedProcess;
241
+ }): Promise<SandboxWorkspaceMutationAdmission> =>
242
+ await advanceWorkspaceGenerationForRetainedProcess(db, {
243
+ accountId: ids.accountId,
244
+ workspaceId: ids.workspaceId,
245
+ sessionId: ids.sessionId,
246
+ processId: process.id,
247
+ operation: op,
248
+ });
249
+ const afterProcessMutation = async ({
250
+ op,
251
+ process,
252
+ admission,
253
+ outcome,
254
+ }: {
255
+ op: string;
256
+ backend: ResolvedActiveBackend;
257
+ process: RoutingRetainedProcess;
258
+ admission: unknown;
259
+ outcome: "resolved" | "rejected";
260
+ result?: unknown;
261
+ }): Promise<void> => {
262
+ if (
263
+ !admission ||
264
+ typeof admission !== "object" ||
265
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
266
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
267
+ "number"
268
+ ) {
269
+ throw new Error("API retained-process mutation settlement lacked its exact admission");
270
+ }
271
+ await verifyRetainedProcessMutationSettlement(db, {
272
+ accountId: ids.accountId,
273
+ workspaceId: ids.workspaceId,
274
+ sessionId: ids.sessionId,
275
+ processId: process.id,
276
+ admission: admission as SandboxWorkspaceMutationAdmission,
277
+ operation: op,
278
+ outcome,
279
+ });
280
+ };
281
+ const settleProcess = async ({
282
+ backend,
283
+ process,
284
+ proof,
285
+ }: {
286
+ backend: ResolvedActiveBackend;
287
+ process: RoutingRetainedProcess;
288
+ proof: RoutingRetainedProcessTerminalProof;
289
+ }): Promise<void> => {
290
+ if (
291
+ backend.sandboxId !== null ||
292
+ backend.leaseEpoch === undefined ||
293
+ backend.providerInstanceId === undefined
294
+ ) {
295
+ return;
296
+ }
297
+ await settleRetainedProcess(db, {
298
+ accountId: ids.accountId,
299
+ workspaceId: ids.workspaceId,
300
+ sessionId: ids.sessionId,
301
+ processId: process.id,
302
+ outcome: proof.outcome,
303
+ exitCode: proof.exitCode,
304
+ reason: proof.reason,
305
+ idleGraceMs: settings.sandboxIdleGraceMs,
306
+ });
307
+ };
104
308
  const resolver = makeActiveBackendResolver({
105
309
  workspaceId: ids.workspaceId,
106
310
  defaultBackend: established.session as RoutableBackendSession,
@@ -118,14 +322,68 @@ export function wrapChannelABoxWithRouting(
118
322
  },
119
323
  controlRpcFactory: controlRpcFactory(bus),
120
324
  relay: relayConfigFromSettings(settings),
325
+ resolveDefaultBackend: async () => ({
326
+ session: established.session as RoutableBackendSession,
327
+ sandboxId: null,
328
+ kind: established.backendId,
329
+ leaseEpoch: ids.homeLease.leaseEpoch,
330
+ providerInstanceId: ids.homeLease.instanceId,
331
+ }),
121
332
  });
122
333
 
123
334
  const proxy = new RoutingSandboxSession({
335
+ defaultResolved: {
336
+ session: established.session as RoutableBackendSession,
337
+ sandboxId: null,
338
+ kind: established.backendId,
339
+ leaseEpoch: ids.homeLease.leaseEpoch,
340
+ providerInstanceId: ids.homeLease.instanceId,
341
+ },
124
342
  readPointer: async () => {
343
+ if (!routingEnabled(settings)) {
344
+ return { activeSandboxId: null, activeEpoch: 0 };
345
+ }
125
346
  const pointer = await readActiveSandbox(db, ids.workspaceId, ids.sessionId);
126
347
  return pointer ?? { activeSandboxId: null, activeEpoch: 0 };
127
348
  },
128
349
  resolveActiveBackend: resolver,
350
+ beforeMutation,
351
+ afterMutation,
352
+ beforeProcessMutation,
353
+ afterProcessMutation,
354
+ settleProcess,
355
+ onDefaultBackendError: async ({ error }) => {
356
+ if (!isProviderSandboxGoneDuringRoutedOperation(ids.homeLease.backend, error)) return null;
357
+ const marked = await markWarmLeaseInstanceLost(db, {
358
+ accountId: ids.accountId,
359
+ workspaceId: ids.workspaceId,
360
+ sandboxGroupId: ids.homeLease.sandboxGroupId,
361
+ expectedEpoch: ids.homeLease.leaseEpoch,
362
+ expectedInstanceId: ids.homeLease.instanceId,
363
+ diagnostic: "provider_not_found_during_routed_operation",
364
+ });
365
+ if (marked.status === "marked" && bus) {
366
+ await appendAndPublishEvents(db, bus, ids.workspaceId, ids.sessionId, [
367
+ {
368
+ type: "sandbox.box.lost",
369
+ payload: { sandboxId: ids.homeLease.instanceId },
370
+ },
371
+ ]).catch(() => undefined);
372
+ }
373
+ const lease = marked.lease;
374
+ const restore = lease?.recovery.restore.status;
375
+ return {
376
+ leaseEpoch: lease?.leaseEpoch ?? ids.homeLease.leaseEpoch,
377
+ recovery:
378
+ marked.status === "stale"
379
+ ? ("superseded" as const)
380
+ : restore === "pending"
381
+ ? ("pending" as const)
382
+ : restore === "degraded"
383
+ ? ("degraded" as const)
384
+ : ("unrecoverable" as const),
385
+ };
386
+ },
129
387
  });
130
388
 
131
389
  return { ...established, session: proxy };