@opengeni/api-router 0.9.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.
@@ -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;
@@ -682,15 +767,18 @@ export async function mintDesktopStream(
682
767
  }
683
768
 
684
769
  // Record the resolved URL on the lease under the epoch fence (rotation +
685
- // disclosure). A fence miss (the box re-established under a newer epoch
686
- // mid-call) is a no-op; we still return the cell we minted for our epoch.
687
- 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, {
688
773
  accountId,
689
774
  workspaceId,
690
775
  sandboxGroupId: session.sandboxGroupId,
691
776
  expectedEpoch: lease.leaseEpoch,
692
777
  dataPlaneUrl: exposed.url,
693
778
  });
779
+ if (!recorded) {
780
+ return null;
781
+ }
694
782
 
695
783
  const mint: DesktopStreamMint = {
696
784
  url: exposed.url,
@@ -744,8 +832,8 @@ export async function mintDesktopStream(
744
832
  // server (ensureTerminalServer), resolves the provider's scoped tunnel for port
745
833
  // 7681 (a SEPARATE tunnel from the 6080 desktop noVNC → a different URL), mints
746
834
  // the scoped per-viewer stream token, and records the resolved URL on the lease's
747
- // terminal_data_plane_url column under the epoch fence. The fast-path re-mints
748
- // 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.
749
837
  //
750
838
  // It does NOT require the desktop to be on — it gates on the separate
751
839
  // sandboxTerminalEnabled toggle. Degradation (no secret, headless backend, ttyd
@@ -841,29 +929,6 @@ export async function mintTerminalStream(
841
929
  return null;
842
930
  }
843
931
 
844
- // FAST PATH: the terminal tunnel URL is stable for the life of the (epoch-fenced)
845
- // box, so when the lease already caches it, mint ONLY a fresh scoped token (HMAC,
846
- // sub-millisecond) against the cached URL — no box resume/exec at all. A rollover
847
- // advances the epoch and clears terminalDataPlaneUrl (commitWarmingToWarm), so a
848
- // cached URL here is always the current epoch's live ttyd tunnel.
849
- if (lease.terminalDataPlaneUrl) {
850
- const nowSeconds = Math.floor(Date.now() / 1000);
851
- const token = await mintStreamToken(secret, {
852
- workspaceId,
853
- sessionId: session.id,
854
- viewerId,
855
- leaseEpoch: lease.leaseEpoch,
856
- port: TERMINAL_STREAM_PORT,
857
- nowSeconds,
858
- });
859
- return {
860
- url: lease.terminalDataPlaneUrl,
861
- token,
862
- expiresAt: new Date((nowSeconds + STREAM_TOKEN_DEFAULT_TTL_SECONDS) * 1000).toISOString(),
863
- leaseEpoch: lease.leaseEpoch,
864
- };
865
- }
866
-
867
932
  // Resume the LIVE box by id (lease.resume_state authoritative), ensure ttyd, and
868
933
  // resolve the 7681 tunnel + mint the scoped token, IN-PROCESS.
869
934
  const envelope = lease.resumeState;
@@ -875,12 +940,16 @@ export async function mintTerminalStream(
875
940
  try {
876
941
  established = input.establish
877
942
  ? await input.establish(envelope)
878
- : await establishSandboxSessionFromEnvelope(settings, envelope, {
879
- sessionId: session.id,
880
- recovery: "resume-only",
881
- backendOverride: session.sandboxBackend,
882
- environment,
883
- });
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
+ );
884
953
  } catch (error) {
885
954
  await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
886
955
  return null;
@@ -918,16 +987,19 @@ export async function mintTerminalStream(
918
987
  throw error;
919
988
  }
920
989
 
921
- // Record the resolved terminal URL on the lease under the epoch fence. A fence
922
- // miss (box re-established under a newer epoch mid-call) is a no-op; we still
923
- // return the cell we minted for our epoch.
924
- 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, {
925
994
  accountId,
926
995
  workspaceId,
927
996
  sandboxGroupId: session.sandboxGroupId,
928
997
  expectedEpoch: lease.leaseEpoch,
929
998
  terminalDataPlaneUrl: exposed.url,
930
999
  });
1000
+ if (!recorded) {
1001
+ return null;
1002
+ }
931
1003
 
932
1004
  return {
933
1005
  url: exposed.url,
@@ -1126,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}$/
1126
1198
  *
1127
1199
  * The GET stream-capabilities handshake mints a token scoped to the CALLING
1128
1200
  * PRINCIPAL — grant.subjectId — which for an API-key principal is a NON-UUID like
1129
- * "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
1130
1202
  * StreamTokenPayload.parse, which escaped as an uncaught 500 (caps-500 bug). The
1131
1203
  * browser's managed-session subject IS a UUID and is returned unchanged, so it is
1132
1204
  * unaffected. A non-UUID principal is mapped to a DETERMINISTIC v5-shaped UUID