@opengeni/api-router 0.7.3 → 0.11.1

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.
@@ -31,12 +31,11 @@ import { githubAppBotIdentity } from "@opengeni/github";
31
31
  import { type Session, type StreamUrlRotatedPayload } from "@opengeni/contracts";
32
32
  import {
33
33
  acquireLease,
34
- commitWarmingToWarm,
35
- failWarmingToCold,
36
34
  getSandbox,
37
35
  getSandboxSessionEnvelope,
38
36
  heartbeatLeaseHolder,
39
37
  loadWorkspaceEnvironmentForRun,
38
+ markSandboxProviderReady,
40
39
  markWarmLeaseInstanceLost,
41
40
  readLease,
42
41
  recordLeaseDataPlaneUrl,
@@ -58,23 +57,21 @@ import {
58
57
  ensureTerminalServer,
59
58
  establishSandboxSessionFromEnvelope,
60
59
  isProviderSandboxNotFoundError,
61
- SandboxResumeStateUnavailableError,
62
60
  exposeStreamPort,
63
61
  desktopCapableBackend,
64
62
  NatsControlRpc,
65
63
  SelfhostedSandboxClient,
66
- serializeEstablishedSandboxEnvelope,
67
- mintStreamToken,
68
- STREAM_TOKEN_DEFAULT_TTL_SECONDS,
69
64
  TERMINAL_STREAM_PORT,
70
65
  DisplayStackUnsupportedError,
71
66
  TerminalServerUnsupportedError,
67
+ verifySandboxExecReadiness,
72
68
  StreamPortUnavailableError,
73
69
  type ControlRpc,
74
70
  type EstablishedSandboxSession,
75
71
  type NatsRequestConnection,
76
72
  } from "@opengeni/runtime/sandbox";
77
73
  import { relayConfigFromSettings } from "@opengeni/core";
74
+ import { establishApiSandboxSpawner } from "./rematerialize";
78
75
 
79
76
  /** The minimal services a viewer op needs: the DB + settings (lease cadence +
80
77
  * the sandbox client construction the leaf reads from settings). The bus is
@@ -84,6 +81,10 @@ export type ViewerServices = {
84
81
  db: Database;
85
82
  settings: Settings;
86
83
  bus?: EventBus;
84
+ /** Provider-establish dependency used by API-direct readiness and stream
85
+ * operations. Production uses the runtime leaf; isolated tests may supply a
86
+ * deterministic provider without replacing a process-global module. */
87
+ establishSandboxSession?: typeof establishSandboxSessionFromEnvelope;
87
88
  };
88
89
 
89
90
  /** A coherent snapshot the routes echo back: the holder id (the viewer's fence-
@@ -93,6 +94,9 @@ export type ViewerAttachResult = {
93
94
  viewerId: string;
94
95
  liveness: LeaseSnapshot["liveness"];
95
96
  leaseEpoch: number;
97
+ workspaceGeneration: number | null;
98
+ archiveGeneration: number | null;
99
+ archiveComplete: boolean;
96
100
  sandboxGroupId: string;
97
101
  // The viewer heartbeat cadence the client must beat at to keep the holder
98
102
  // alive (shorter than the viewer-holder TTL the reaper enforces).
@@ -206,6 +210,12 @@ export async function attachViewer(
206
210
 
207
211
  // FENCED: a newer epoch re-established the box. Release our just-registered
208
212
  // holder and surface a 409 — the client re-reads capabilities and re-attaches.
213
+ if (acquired.role === "blocked") {
214
+ await release();
215
+ throw new HTTPException(409, {
216
+ message: `sandbox recovery ${acquired.lease.recovery.restore.status} at epoch ${acquired.lease.leaseEpoch}`,
217
+ });
218
+ }
209
219
  if (acquired.role === "fenced") {
210
220
  await release();
211
221
  throw new HTTPException(409, {
@@ -235,46 +245,31 @@ export async function attachViewer(
235
245
  // fresh box and replays the archive via hydrateWorkspace, so /workspace
236
246
  // survives the box churn (sandbox-file-persistence). No archive -> the bare
237
247
  // session envelope (a never-warmed cold start).
238
- const spawnEnvelope = acquired.lease.resumeState ?? envelope;
239
- established = await establishSandboxSessionFromEnvelope(settings, spawnEnvelope, {
240
- sessionId: session.id,
241
- recovery: "create-or-restore",
242
- backendOverride: session.sandboxBackend,
243
- environment,
244
- });
245
- // Fold the LIVE box into a re-resumable envelope and persist it as the
246
- // lease's resume_state, so EVERY later op (another viewer, a Channel-A
247
- // call, the reaper) resumes THIS box by id instead of cold-creating a
248
- // rival. Fall back to the session envelope only when serialize is
249
- // unavailable. (Without this the box churned: each op spawned its own box.)
250
- const resumeEnvelope =
251
- (await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
252
- const committed = await commitWarmingToWarm(db, {
248
+ const result = await establishApiSandboxSpawner({
249
+ db,
250
+ settings,
253
251
  accountId,
254
252
  workspaceId,
255
253
  sandboxGroupId,
254
+ sessionId: session.id,
255
+ backend: session.sandboxBackend,
256
+ environment,
256
257
  expectedEpoch,
257
- instanceId: established.instanceId,
258
- // The desktop tunnel-URL mint is P4; record null for now.
258
+ acquiredLease: acquired.lease,
259
+ fallbackEnvelope: envelope,
259
260
  dataPlaneUrl: null,
260
- resumeBackendId: established.backendId,
261
- resumeState: resumeEnvelope,
262
- leaseTtlMs,
263
261
  });
264
- if (!committed.committed || !committed.lease) {
265
- // A reaper reset our warming row (we were too slow) or a sibling
266
- // re-established and bumped the epoch. Release our holder and surface a
267
- // 409. NEVER provider-delete the box (it rides the provider idle-timeout).
268
- await release();
269
- throw new SandboxLeaseSupersededError(sandboxGroupId, expectedEpoch);
270
- }
262
+ established = result.established;
271
263
  return {
272
264
  viewerId,
273
- liveness: committed.lease.liveness,
274
- leaseEpoch: committed.lease.leaseEpoch,
265
+ liveness: result.lease.liveness,
266
+ leaseEpoch: result.lease.leaseEpoch,
267
+ workspaceGeneration: result.lease.workspaceGeneration,
268
+ archiveGeneration: result.lease.archiveGeneration,
269
+ archiveComplete: result.lease.archiveComplete,
275
270
  sandboxGroupId,
276
271
  viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
277
- dataPlaneUrl: committed.lease.dataPlaneUrl,
272
+ dataPlaneUrl: result.lease.dataPlaneUrl,
278
273
  };
279
274
  } catch (error) {
280
275
  if (error instanceof SandboxLeaseSupersededError) {
@@ -282,11 +277,8 @@ export async function attachViewer(
282
277
  message: `sandbox lease superseded (epoch ${error.leaseEpoch}); re-read capabilities and re-attach`,
283
278
  });
284
279
  }
285
- // Caught spawn failure: roll the warming row back to cold so the next
286
- // arrival (a turn or another viewer) re-acquires and re-spawns. Holders
287
- // are intentionally kept by failWarmingToCold for the re-acquire; then
288
- // release our own holder so we don't pin a cold lease.
289
- await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
280
+ // The shared API spawner helper already terminated any unpublished box
281
+ // and durably recorded the epoch-fenced failure state.
290
282
  await release();
291
283
  // Mirror the Channel-A spawner (channel-a.ts): a provider/config failure to
292
284
  // bring up the cold box is a client-actionable 409 ("sandbox not available;
@@ -305,17 +297,134 @@ export async function attachViewer(
305
297
  }
306
298
  }
307
299
 
308
- // ATTACHED / REARMED: the box is live (or a sibling is mid-warm). The viewer
309
- // holder alone keeps it warm no establish needed (the holder lifecycle is
310
- // the P1.4 deliverable; P4 mints the pixel URL on the negotiation read).
311
- return {
312
- viewerId,
313
- liveness: acquired.lease.liveness,
314
- leaseEpoch: acquired.lease.leaseEpoch,
315
- sandboxGroupId,
316
- viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
317
- dataPlaneUrl: acquired.lease.dataPlaneUrl,
318
- };
300
+ // ATTACHED / REARMED: a lease row is not provider/readiness evidence. Wait for
301
+ // a sibling spawner if necessary, then resume the exact instance and pass the
302
+ // same bounded command probe used by every create path. A provider NotFound
303
+ // retires this exact epoch and returns 409; it never becomes a false-success
304
+ // viewer attachment.
305
+ try {
306
+ const deadline = Date.now() + settings.sandboxWarmingTimeoutMs;
307
+ let live = acquired.lease;
308
+ while (live.liveness === "warming" && Date.now() < deadline) {
309
+ await new Promise((resolve) => setTimeout(resolve, 100));
310
+ const refreshed = await readLease(db, workspaceId, sandboxGroupId);
311
+ if (!refreshed) break;
312
+ live = refreshed;
313
+ }
314
+ if (
315
+ live.liveness !== "warm" ||
316
+ live.instanceId === null ||
317
+ live.recovery.workspace.status === "degraded" ||
318
+ live.recovery.workspace.status === "unrecoverable"
319
+ ) {
320
+ throw new HTTPException(409, {
321
+ message: `sandbox is ${live.recovery.restore.status} at epoch ${live.leaseEpoch}`,
322
+ });
323
+ }
324
+ const environment = await sessionAttachEnvironment(services, workspaceId, session);
325
+ let observed: EstablishedSandboxSession | undefined;
326
+ try {
327
+ const establish = services.establishSandboxSession ?? establishSandboxSessionFromEnvelope;
328
+ observed = await establish(settings, live.resumeState, {
329
+ sessionId: session.id,
330
+ recovery: "resume-only",
331
+ backendOverride: session.sandboxBackend,
332
+ environment,
333
+ });
334
+ await verifySandboxExecReadiness(observed);
335
+ } catch (error) {
336
+ if (
337
+ await retireMissingWarmLease(
338
+ services,
339
+ { accountId, workspaceId, session, lease: live },
340
+ error,
341
+ )
342
+ ) {
343
+ throw new HTTPException(409, {
344
+ message: `sandbox instance was lost; retry to restore it`,
345
+ });
346
+ }
347
+ throw error;
348
+ } finally {
349
+ await dropEstablishedHandle(observed);
350
+ }
351
+ const ready = await markSandboxProviderReady(db, {
352
+ accountId,
353
+ workspaceId,
354
+ sandboxGroupId,
355
+ expectedEpoch: live.leaseEpoch,
356
+ expectedInstanceId: live.instanceId,
357
+ });
358
+ if (!ready.wrote || !ready.lease) {
359
+ throw new HTTPException(409, {
360
+ message: `sandbox lease superseded during readiness verification; retry`,
361
+ });
362
+ }
363
+ return {
364
+ viewerId,
365
+ liveness: ready.lease.liveness,
366
+ leaseEpoch: ready.lease.leaseEpoch,
367
+ workspaceGeneration: ready.lease.workspaceGeneration,
368
+ archiveGeneration: ready.lease.archiveGeneration,
369
+ archiveComplete: ready.lease.archiveComplete,
370
+ sandboxGroupId,
371
+ viewerHeartbeatIntervalMs: viewerHeartbeatIntervalMs(settings),
372
+ dataPlaneUrl: ready.lease.dataPlaneUrl,
373
+ };
374
+ } catch (error) {
375
+ await release();
376
+ throw error;
377
+ }
378
+ }
379
+
380
+ export type SessionGroupReadinessHold = {
381
+ lease: LeaseSnapshot;
382
+ /** Idempotently release the viewer holder after route publication settles. */
383
+ release: () => Promise<void>;
384
+ };
385
+
386
+ /** Readiness callback for fleet attach/swap. It acquires one disposable viewer
387
+ * holder through the same provider-verifying path as the UI and RETURNS that
388
+ * holder to the swap owner. The holder must remain live until route publication
389
+ * settles, otherwise the reaper can drain the just-verified target between the
390
+ * readiness probe and the route CAS. A first 409 may have fenced a missing warm
391
+ * provider; one bounded retry lets the normal cold->warming election rematerialize
392
+ * it. */
393
+ export async function ensureSessionGroupReady(
394
+ services: ViewerServices,
395
+ input: { accountId: string; workspaceId: string; session: Session },
396
+ ): Promise<SessionGroupReadinessHold> {
397
+ let lastError: unknown;
398
+ for (let attempt = 0; attempt < 2; attempt += 1) {
399
+ let release: (() => Promise<void>) | undefined;
400
+ try {
401
+ const attached = await attachViewer(services, input);
402
+ let releasePromise: Promise<void> | undefined;
403
+ release = () =>
404
+ (releasePromise ??= detachViewer(services, {
405
+ accountId: input.accountId,
406
+ workspaceId: input.workspaceId,
407
+ sandboxGroupId: attached.sandboxGroupId,
408
+ viewerId: attached.viewerId,
409
+ }).then(() => undefined));
410
+ const lease = await readLease(services.db, input.workspaceId, attached.sandboxGroupId);
411
+ if (
412
+ lease?.liveness === "warm" &&
413
+ lease.recovery.provider.status === "exists" &&
414
+ lease.recovery.workspace.status === "ready"
415
+ ) {
416
+ return { lease, release };
417
+ }
418
+ lastError = new Error("sandbox did not reach verified workspace readiness");
419
+ } catch (error) {
420
+ lastError = error;
421
+ if (release) await release().catch(() => undefined);
422
+ if (!release && (!(error instanceof HTTPException) || error.status !== 409)) break;
423
+ continue;
424
+ }
425
+ if (release) await release().catch(() => undefined);
426
+ }
427
+ throw lastError ?? new Error("sandbox readiness could not be established");
319
428
  }
320
429
 
321
430
  /**
@@ -414,8 +523,7 @@ async function retireMissingWarmLease(
414
523
  ): Promise<boolean> {
415
524
  if (
416
525
  input.lease.instanceId === null ||
417
- (!(error instanceof SandboxResumeStateUnavailableError) &&
418
- !isProviderSandboxNotFoundError(input.session.sandboxBackend, error))
526
+ !isProviderSandboxNotFoundError(input.session.sandboxBackend, error)
419
527
  ) {
420
528
  return false;
421
529
  }
@@ -528,8 +636,8 @@ export type MintDesktopStreamInput = {
528
636
  * Idempotent display-stack + resolveExposedPort are safe to call N times. The
529
637
  * resolved URL is recorded on the lease (data_plane_url) under the epoch fence; a
530
638
  * stale-epoch write (the box re-established under a newer epoch mid-call) is a
531
- * no-op and we return the freshly-minted cell anyway (it is for the epoch we
532
- * resumed under; the next op reconciles).
639
+ * no-op and the mint returns null rather than disclosing a capability for the
640
+ * superseded provider epoch.
533
641
  */
534
642
  export async function mintDesktopStream(
535
643
  services: ViewerServices,
@@ -595,33 +703,6 @@ export async function mintDesktopStream(
595
703
  return null;
596
704
  }
597
705
 
598
- // FAST PATH (P4.2 perf): when the lease already holds the data-plane URL for
599
- // this epoch, the box is warm, exposed, and the display stack is already up.
600
- // Re-resuming the box by id (Modal resume-by-id is ~40s) + re-running
601
- // ensureDisplayStack + exposeStreamPort on EVERY stream-capabilities poll made
602
- // the desktop look like it was "starting" forever. The tunnel URL is stable for
603
- // the life of the (epoch-fenced) box, so mint ONLY a fresh scoped token (HMAC,
604
- // sub-millisecond) against the cached URL and return — no box touch at all. A
605
- // rollover advances the epoch and re-records dataPlaneUrl via the slow path, so
606
- // a cached URL here is always the current epoch's live tunnel.
607
- if (lease.dataPlaneUrl) {
608
- const nowSeconds = Math.floor(Date.now() / 1000);
609
- const token = await mintStreamToken(secret, {
610
- workspaceId,
611
- sessionId: session.id,
612
- viewerId,
613
- leaseEpoch: lease.leaseEpoch,
614
- nowSeconds,
615
- });
616
- return {
617
- url: lease.dataPlaneUrl,
618
- token,
619
- expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
620
- resolution: defaultResolution(settings),
621
- leaseEpoch: lease.leaseEpoch,
622
- };
623
- }
624
-
625
706
  // Resume the LIVE box by id from the authoritative lease descriptor. An
626
707
  // attached stream resolver has no creation authority and never substitutes a
627
708
  // per-session envelope for missing lease state.
@@ -634,12 +715,16 @@ export async function mintDesktopStream(
634
715
  try {
635
716
  established = input.establish
636
717
  ? await input.establish(envelope)
637
- : await establishSandboxSessionFromEnvelope(settings, envelope, {
638
- sessionId: session.id,
639
- recovery: "resume-only",
640
- backendOverride: session.sandboxBackend,
641
- environment,
642
- });
718
+ : await (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
719
+ settings,
720
+ envelope,
721
+ {
722
+ sessionId: session.id,
723
+ recovery: "resume-only",
724
+ backendOverride: session.sandboxBackend,
725
+ environment,
726
+ },
727
+ );
643
728
  } catch (error) {
644
729
  await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
645
730
  return null;
@@ -648,7 +733,13 @@ export async function mintDesktopStream(
648
733
  // Idempotent display stack (flock-guarded; a no-op when already up). A box
649
734
  // that genuinely can't run the stack degrades to transport:null, not a throw.
650
735
  try {
651
- await ensureDisplayStack(established.session);
736
+ await ensureDisplayStack(established.session, {
737
+ telemetryContext: {
738
+ callerKind: "viewer",
739
+ ...(lease.instanceId ? { sandboxId: lease.instanceId } : {}),
740
+ leaseEpoch: lease.leaseEpoch,
741
+ },
742
+ });
652
743
  } catch (error) {
653
744
  if (error instanceof DisplayStackUnsupportedError) {
654
745
  return null;
@@ -676,15 +767,18 @@ export async function mintDesktopStream(
676
767
  }
677
768
 
678
769
  // Record the resolved URL on the lease under the epoch fence (rotation +
679
- // disclosure). A fence miss (the box re-established under a newer epoch
680
- // mid-call) is a no-op; we still return the cell we minted for our epoch.
681
- await recordLeaseDataPlaneUrl(db, {
770
+ // disclosure). A fence miss means this capability belongs to a stale
771
+ // provider epoch; never return it to the caller.
772
+ const recorded = await recordLeaseDataPlaneUrl(db, {
682
773
  accountId,
683
774
  workspaceId,
684
775
  sandboxGroupId: session.sandboxGroupId,
685
776
  expectedEpoch: lease.leaseEpoch,
686
777
  dataPlaneUrl: exposed.url,
687
778
  });
779
+ if (!recorded) {
780
+ return null;
781
+ }
688
782
 
689
783
  const mint: DesktopStreamMint = {
690
784
  url: exposed.url,
@@ -738,8 +832,8 @@ export async function mintDesktopStream(
738
832
  // server (ensureTerminalServer), resolves the provider's scoped tunnel for port
739
833
  // 7681 (a SEPARATE tunnel from the 6080 desktop noVNC → a different URL), mints
740
834
  // the scoped per-viewer stream token, and records the resolved URL on the lease's
741
- // terminal_data_plane_url column under the epoch fence. The fast-path re-mints
742
- // ONLY a fresh token against the cached terminal URL (no box touch).
835
+ // terminal_data_plane_url column under the epoch fence. Every mint re-establishes
836
+ // provider/readiness state; the cached URL is never used as a blind fast path.
743
837
  //
744
838
  // It does NOT require the desktop to be on — it gates on the separate
745
839
  // sandboxTerminalEnabled toggle. Degradation (no secret, headless backend, ttyd
@@ -835,29 +929,6 @@ export async function mintTerminalStream(
835
929
  return null;
836
930
  }
837
931
 
838
- // FAST PATH: the terminal tunnel URL is stable for the life of the (epoch-fenced)
839
- // box, so when the lease already caches it, mint ONLY a fresh scoped token (HMAC,
840
- // sub-millisecond) against the cached URL — no box resume/exec at all. A rollover
841
- // advances the epoch and clears terminalDataPlaneUrl (commitWarmingToWarm), so a
842
- // cached URL here is always the current epoch's live ttyd tunnel.
843
- if (lease.terminalDataPlaneUrl) {
844
- const nowSeconds = Math.floor(Date.now() / 1000);
845
- const token = await mintStreamToken(secret, {
846
- workspaceId,
847
- sessionId: session.id,
848
- viewerId,
849
- leaseEpoch: lease.leaseEpoch,
850
- port: TERMINAL_STREAM_PORT,
851
- nowSeconds,
852
- });
853
- return {
854
- url: lease.terminalDataPlaneUrl,
855
- token,
856
- expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
857
- leaseEpoch: lease.leaseEpoch,
858
- };
859
- }
860
-
861
932
  // Resume the LIVE box by id (lease.resume_state authoritative), ensure ttyd, and
862
933
  // resolve the 7681 tunnel + mint the scoped token, IN-PROCESS.
863
934
  const envelope = lease.resumeState;
@@ -869,12 +940,16 @@ export async function mintTerminalStream(
869
940
  try {
870
941
  established = input.establish
871
942
  ? await input.establish(envelope)
872
- : await establishSandboxSessionFromEnvelope(settings, envelope, {
873
- sessionId: session.id,
874
- recovery: "resume-only",
875
- backendOverride: session.sandboxBackend,
876
- environment,
877
- });
943
+ : await (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
944
+ settings,
945
+ envelope,
946
+ {
947
+ sessionId: session.id,
948
+ recovery: "resume-only",
949
+ backendOverride: session.sandboxBackend,
950
+ environment,
951
+ },
952
+ );
878
953
  } catch (error) {
879
954
  await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
880
955
  return null;
@@ -912,16 +987,19 @@ export async function mintTerminalStream(
912
987
  throw error;
913
988
  }
914
989
 
915
- // Record the resolved terminal URL on the lease under the epoch fence. A fence
916
- // miss (box re-established under a newer epoch mid-call) is a no-op; we still
917
- // return the cell we minted for our epoch.
918
- await recordLeaseTerminalDataPlaneUrl(db, {
990
+ // Record the resolved terminal URL on the lease under the epoch fence. A
991
+ // fence miss means this capability belongs to a stale provider epoch; never
992
+ // return it to the caller.
993
+ const recorded = await recordLeaseTerminalDataPlaneUrl(db, {
919
994
  accountId,
920
995
  workspaceId,
921
996
  sandboxGroupId: session.sandboxGroupId,
922
997
  expectedEpoch: lease.leaseEpoch,
923
998
  terminalDataPlaneUrl: exposed.url,
924
999
  });
1000
+ if (!recorded) {
1001
+ return null;
1002
+ }
925
1003
 
926
1004
  return {
927
1005
  url: exposed.url,
@@ -1120,7 +1198,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
1120
1198
  *
1121
1199
  * The GET stream-capabilities handshake mints a token scoped to the CALLING
1122
1200
  * PRINCIPAL — grant.subjectId — which for an API-key principal is a NON-UUID like
1123
- * "configured:key". Passing that straight to mintStreamToken threw a ZodError in
1201
+ * "configured:key". Passing that straight to the stream-token payload parser threw a ZodError in
1124
1202
  * StreamTokenPayload.parse, which escaped as an uncaught 500 (caps-500 bug). The
1125
1203
  * browser's managed-session subject IS a UUID and is returned unchanged, so it is
1126
1204
  * unaffected. A non-UUID principal is mapped to a DETERMINISTIC v5-shaped UUID