@opengeni/api-router 0.9.0 → 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.
@@ -3,7 +3,7 @@
3
3
  // The structured services (FileSystem / Git / Terminal) are SYNCHRONOUS point
4
4
  // queries served client -> API -> box IN-PROCESS. Each call:
5
5
  //
6
- // 1. acquires a viewer-kind lease holder (warming the box when cold — the
6
+ // 1. acquires an exact direct-request lease holder (warming the box when cold — the
7
7
  // same cold->warming CAS attachViewer runs; a Postgres txn the API OWNS),
8
8
  // 2. resumes the box BY ID from the group lease's resume_state envelope,
9
9
  // 3. builds ONE SandboxChannelAService around the live `session` handle,
@@ -28,8 +28,6 @@ import { githubAppBotIdentity } from "@opengeni/github";
28
28
  import type { Session } from "@opengeni/contracts";
29
29
  import {
30
30
  acquireLease,
31
- commitWarmingToWarm,
32
- failWarmingToCold,
33
31
  getSandboxSessionEnvelope,
34
32
  loadWorkspaceEnvironmentForRun,
35
33
  markWarmLeaseInstanceLost,
@@ -44,8 +42,6 @@ import { HTTPException } from "hono/http-exception";
44
42
  import {
45
43
  establishSandboxSessionFromEnvelope,
46
44
  isProviderSandboxNotFoundError,
47
- SandboxResumeStateUnavailableError,
48
- serializeEstablishedSandboxEnvelope,
49
45
  SandboxChannelAService,
50
46
  ChannelAConflictError,
51
47
  ChannelANotFoundError,
@@ -57,8 +53,10 @@ import {
57
53
  withRunCredentialsSession,
58
54
  type ChannelASession,
59
55
  type EstablishedSandboxSession,
56
+ type RoutingSandboxSession,
60
57
  } from "@opengeni/runtime/sandbox";
61
- import { routingEnabled, wrapChannelABoxWithRouting } from "@opengeni/core";
58
+ import { wrapChannelABoxWithRouting } from "@opengeni/core";
59
+ import { establishApiSandboxSpawner } from "./rematerialize";
62
60
 
63
61
  export type ChannelAServices = {
64
62
  db: Database;
@@ -79,10 +77,12 @@ export type ChannelAContext = {
79
77
  export type ChannelAHandle = {
80
78
  service: SandboxChannelAService;
81
79
  lease: LeaseSnapshot;
80
+ routingSession: RoutingSandboxSession;
81
+ requestId: string;
82
82
  };
83
83
 
84
84
  /**
85
- * Run a Channel-A op against a live box, API-direct. Acquires a viewer holder
85
+ * Run a Channel-A op against a live box, API-direct. Acquires an exact direct holder
86
86
  * (warming the box when cold), resumes by id, builds the service, runs `fn`, and
87
87
  * ALWAYS releases the holder + drops the handle in `finally`. Maps the service's
88
88
  * typed errors to HTTP status (the route never sees a raw ChannelA*Error).
@@ -103,7 +103,8 @@ export async function withChannelA<T>(
103
103
  }
104
104
 
105
105
  const sandboxGroupId = session.sandboxGroupId;
106
- const viewerId = crypto.randomUUID();
106
+ const requestId = crypto.randomUUID();
107
+ const holderId = `direct:${requestId}`;
107
108
  const leaseTtlMs = settings.sandboxLeaseTtlMs;
108
109
 
109
110
  const release = async (): Promise<void> => {
@@ -111,19 +112,19 @@ export async function withChannelA<T>(
111
112
  accountId,
112
113
  workspaceId,
113
114
  sandboxGroupId,
114
- kind: "viewer",
115
- holderId: viewerId,
115
+ kind: "direct",
116
+ holderId,
116
117
  idleGraceMs: settings.sandboxIdleGraceMs,
117
118
  });
118
119
  };
119
120
 
120
- // Acquire a viewer holder; the cold->warming CAS spawns the box when cold.
121
+ // Acquire exact request authority; the cold->warming CAS spawns the box when cold.
121
122
  const acquired = await acquireLease(db, {
122
123
  accountId,
123
124
  workspaceId,
124
125
  sandboxGroupId,
125
- kind: "viewer",
126
- holderId: viewerId,
126
+ kind: "direct",
127
+ holderId,
127
128
  subjectId: session.id,
128
129
  backend: session.sandboxBackend,
129
130
  os: session.sandboxOs,
@@ -131,6 +132,12 @@ export async function withChannelA<T>(
131
132
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
132
133
  });
133
134
 
135
+ if (acquired.role === "blocked") {
136
+ await release();
137
+ throw new HTTPException(409, {
138
+ message: `sandbox recovery ${acquired.lease.recovery.restore.status} at epoch ${acquired.lease.leaseEpoch}`,
139
+ });
140
+ }
134
141
  if (acquired.role === "fenced") {
135
142
  await release();
136
143
  throw new HTTPException(409, {
@@ -185,43 +192,28 @@ export async function withChannelA<T>(
185
192
  // the bare session envelope (a never-warmed cold start). The order matters:
186
193
  // resume_state is the lease's authoritative box descriptor; the session
187
194
  // `_sandbox` envelope is only the per-session fallback.
188
- const spawnEnvelope = acquired.lease.resumeState ?? envelope;
189
195
  try {
190
- established = await establishSandboxSessionFromEnvelope(settings, spawnEnvelope, {
196
+ const result = await establishApiSandboxSpawner({
197
+ db,
198
+ settings,
199
+ accountId,
200
+ workspaceId,
201
+ sandboxGroupId,
191
202
  sessionId: session.id,
192
- recovery: "create-or-restore",
193
- backendOverride: session.sandboxBackend,
203
+ backend: session.sandboxBackend,
194
204
  environment,
205
+ expectedEpoch,
206
+ acquiredLease: acquired.lease,
207
+ fallbackEnvelope: envelope,
208
+ dataPlaneUrl: acquired.lease.dataPlaneUrl,
195
209
  });
210
+ established = result.established;
211
+ leaseSnapshot = result.lease;
196
212
  } catch (error) {
197
- await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
198
213
  throw new HTTPException(409, {
199
214
  message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
200
215
  });
201
216
  }
202
- // Persist the LIVE box as the lease's resume_state so the NEXT op resumes
203
- // this box by id rather than cold-creating a rival (the box-churn the
204
- // prove-it surfaced). Fall back to the session envelope when serialize is
205
- // unavailable.
206
- const resumeEnvelope =
207
- (await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
208
- const committed = await commitWarmingToWarm(db, {
209
- accountId,
210
- workspaceId,
211
- sandboxGroupId,
212
- expectedEpoch,
213
- instanceId: established.instanceId,
214
- dataPlaneUrl: acquired.lease.dataPlaneUrl,
215
- resumeBackendId: established.backendId,
216
- resumeState: resumeEnvelope,
217
- leaseTtlMs,
218
- });
219
- if (!committed.committed || !committed.lease) {
220
- throw new HTTPException(409, {
221
- message: `sandbox lease superseded (epoch ${expectedEpoch}); retry`,
222
- });
223
- }
224
- leaseSnapshot = committed.lease;
225
217
  } else {
226
218
  // ATTACHED / REARMED: the box is live. Read the lease to get the
227
219
  // authoritative resume_state, then resume by id for this op.
@@ -245,10 +237,7 @@ export async function withChannelA<T>(
245
237
  environment,
246
238
  });
247
239
  } catch (error) {
248
- if (
249
- !(error instanceof SandboxResumeStateUnavailableError) &&
250
- !isProviderSandboxNotFoundError(session.sandboxBackend, error)
251
- ) {
240
+ if (!isProviderSandboxNotFoundError(session.sandboxBackend, error)) {
252
241
  throw error;
253
242
  }
254
243
  const marked = await markWarmLeaseInstanceLost(db, {
@@ -284,19 +273,25 @@ export async function withChannelA<T>(
284
273
  );
285
274
  };
286
275
 
287
- // M7 hot-swap: when the selfhosted feature is on, route the Channel-A op to
288
- // the session's currently-active sandbox (not always the group box). The
289
- // proxy re-reads (active_sandbox_id, active_epoch) on each session method the
290
- // service calls and dispatches to the active backend (the group box by
291
- // default, or a swapped-to selfhosted machine). With the flag off the
292
- // established group session is used unchanged.
293
- const routedSession = routingEnabled(settings)
294
- ? wrapChannelABoxWithRouting(
295
- { db, settings, bus },
296
- { workspaceId, sessionId: session.id },
297
- established,
298
- ).session
299
- : established.session;
276
+ // Route every call through the same proxy, even when hot-swap is disabled:
277
+ // routing may be dormant, but its direct mutation admission is mandatory for
278
+ // every persistable provider write.
279
+ const routedSession = wrapChannelABoxWithRouting(
280
+ { db, settings, bus },
281
+ {
282
+ accountId,
283
+ workspaceId,
284
+ sessionId: session.id,
285
+ homeLease: {
286
+ sandboxGroupId,
287
+ leaseEpoch: leaseSnapshot.leaseEpoch,
288
+ instanceId: leaseSnapshot.instanceId!,
289
+ backend: session.sandboxBackend,
290
+ },
291
+ directRequest: { requestId, holderId },
292
+ },
293
+ established,
294
+ ).session as RoutingSandboxSession;
300
295
  const credentialSession = withRunCredentialsSession(routedSession as object, session.id);
301
296
  const scopedSession = environment.OPENGENI_TOOLSPACE_TOKEN_FILE
302
297
  ? withToolspaceTokenSession(
@@ -311,7 +306,7 @@ export async function withChannelA<T>(
311
306
  emit,
312
307
  });
313
308
 
314
- return await fn({ service, lease: leaseSnapshot });
309
+ return await fn({ service, lease: leaseSnapshot, routingSession: routedSession, requestId });
315
310
  } catch (error) {
316
311
  throw mapChannelAError(error);
317
312
  } finally {
@@ -188,6 +188,7 @@ export async function listMachines(
188
188
  // null active pointer routes to. Only present in an in-session view.
189
189
  if (session) {
190
190
  const groupActive = activeSandboxId === null;
191
+ const groupLease = await readLease(db, workspaceId, session.sandboxGroupId);
191
192
  machines.push(
192
193
  MachineView.parse({
193
194
  sandboxId: session.sandboxGroupId,
@@ -197,6 +198,9 @@ export async function listMachines(
197
198
  state: "online",
198
199
  active: groupActive,
199
200
  isSessionGroup: true,
201
+ workspaceGeneration: groupLease?.workspaceGeneration ?? null,
202
+ archiveGeneration: groupLease?.archiveGeneration ?? null,
203
+ archiveComplete: groupLease?.archiveComplete ?? false,
200
204
  // The Modal group box is a cloud Linux box; its precise OS/arch is not
201
205
  // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
202
206
  os: "linux",
@@ -249,6 +253,9 @@ export async function listMachines(
249
253
  state,
250
254
  active: activeSandboxId === sandbox.id,
251
255
  isSessionGroup: false,
256
+ workspaceGeneration: null,
257
+ archiveGeneration: null,
258
+ archiveComplete: false,
252
259
  os: enrollment.os,
253
260
  arch: enrollment.arch,
254
261
  hasDisplay: enrollment.hasDisplay,
@@ -0,0 +1,287 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import {
3
+ beginSandboxRematerialization,
4
+ commitWarmingToWarm,
5
+ failSandboxRematerialization,
6
+ failWarmingToCold,
7
+ markSandboxRestoreVerifying,
8
+ recordWarmingSandboxCreated,
9
+ SandboxLeaseRecoveryBlockedError,
10
+ SandboxLeaseSupersededError,
11
+ type Database,
12
+ type LeaseSnapshot,
13
+ } from "@opengeni/db";
14
+ import {
15
+ establishSandboxSessionFromEnvelope,
16
+ isProviderSandboxNotFoundError,
17
+ requirePersistableReplacementSandboxEnvelope,
18
+ serializeReplacementSandboxEnvelope,
19
+ tagModalSandbox,
20
+ verifySandboxExecReadiness,
21
+ WorkspaceArchiveIntegrityError,
22
+ type EstablishedSandboxSession,
23
+ type WorkspaceArchiveDescriptor,
24
+ } from "@opengeni/runtime/sandbox";
25
+
26
+ function hasWorkspaceArchive(envelope: Record<string, unknown> | null): boolean {
27
+ const sessionState =
28
+ envelope?.sessionState && typeof envelope.sessionState === "object"
29
+ ? (envelope.sessionState as Record<string, unknown>)
30
+ : null;
31
+ return (
32
+ typeof sessionState?.workspaceArchive === "string" && sessionState.workspaceArchive.length > 0
33
+ );
34
+ }
35
+
36
+ function withoutProviderIdentity(
37
+ envelope: Record<string, unknown> | null,
38
+ ): Record<string, unknown> | null {
39
+ if (!envelope) return null;
40
+ const sessionState =
41
+ envelope.sessionState && typeof envelope.sessionState === "object"
42
+ ? (envelope.sessionState as Record<string, unknown>)
43
+ : null;
44
+ if (!sessionState) return envelope;
45
+ const { providerState: _providerState, ...providerIndependentState } = sessionState;
46
+ return { ...envelope, sessionState: providerIndependentState };
47
+ }
48
+
49
+ async function terminateCreated(established: EstablishedSandboxSession | null): Promise<boolean> {
50
+ if (!established) return true;
51
+ const client = established.client as { delete?: (state: unknown) => Promise<unknown> };
52
+ try {
53
+ if (typeof client.delete === "function" && established.sessionState !== undefined) {
54
+ await client.delete(established.sessionState);
55
+ return true;
56
+ }
57
+ const session = established.session as {
58
+ terminate?: () => Promise<unknown>;
59
+ kill?: () => Promise<unknown>;
60
+ close?: () => Promise<unknown>;
61
+ closed?: boolean;
62
+ };
63
+ if (session.terminate) await session.terminate();
64
+ else if (session.kill) await session.kill();
65
+ else if (session.close && !session.closed) await session.close();
66
+ else return false;
67
+ return true;
68
+ } catch (error) {
69
+ return isProviderSandboxNotFoundError(established.backendId, error);
70
+ }
71
+ }
72
+
73
+ /** The sole API-direct cold->warming owner path used by Channel A and viewer
74
+ * attach. It never publishes warm until archive identity, hydrated tree, command
75
+ * routing, provider identity, and the selected rematerialization attempt all
76
+ * agree under one lease epoch. */
77
+ export async function establishApiSandboxSpawner(input: {
78
+ db: Database;
79
+ settings: Settings;
80
+ accountId: string;
81
+ workspaceId: string;
82
+ sandboxGroupId: string;
83
+ sessionId: string;
84
+ backend: string;
85
+ environment: Record<string, string>;
86
+ expectedEpoch: number;
87
+ acquiredLease: LeaseSnapshot;
88
+ fallbackEnvelope: Record<string, unknown> | null;
89
+ dataPlaneUrl: string | null;
90
+ }): Promise<{ established: EstablishedSandboxSession; lease: LeaseSnapshot }> {
91
+ const fallbackArchiveEnvelope =
92
+ input.acquiredLease.recovery.archive.status === "none" &&
93
+ hasWorkspaceArchive(input.fallbackEnvelope)
94
+ ? withoutProviderIdentity(input.fallbackEnvelope)
95
+ : null;
96
+ const spawnEnvelope =
97
+ fallbackArchiveEnvelope ?? input.acquiredLease.resumeState ?? input.fallbackEnvelope;
98
+ const archiveSource =
99
+ input.acquiredLease.recovery.archive.status === "none"
100
+ ? fallbackArchiveEnvelope
101
+ : input.acquiredLease.resumeState;
102
+ let established: EstablishedSandboxSession | null = null;
103
+ let rematerialization: { id: string; selectedRevision: string } | null = null;
104
+ try {
105
+ if (
106
+ input.acquiredLease.recovery.archive.status === "available" ||
107
+ hasWorkspaceArchive(archiveSource)
108
+ ) {
109
+ const id = crypto.randomUUID();
110
+ const begun = await beginSandboxRematerialization(input.db, {
111
+ accountId: input.accountId,
112
+ workspaceId: input.workspaceId,
113
+ sandboxGroupId: input.sandboxGroupId,
114
+ expectedEpoch: input.expectedEpoch,
115
+ rematerializationId: id,
116
+ archiveSource,
117
+ });
118
+ if (begun.status !== "started") {
119
+ if (begun.code === "stale_epoch" || begun.code === "attempt_conflict") {
120
+ throw new SandboxLeaseSupersededError(
121
+ input.sandboxGroupId,
122
+ begun.lease?.leaseEpoch ?? input.expectedEpoch,
123
+ );
124
+ }
125
+ throw new SandboxLeaseRecoveryBlockedError(
126
+ input.sandboxGroupId,
127
+ begun.lease?.leaseEpoch ?? input.expectedEpoch,
128
+ begun.code === "archive_unverified" ? "restore_degraded" : "restore_unrecoverable",
129
+ begun.lease?.recovery ?? input.acquiredLease.recovery,
130
+ );
131
+ }
132
+ const selectedRevision = begun.lease.recovery.restore.selectedRevision;
133
+ if (!selectedRevision) {
134
+ throw new WorkspaceArchiveIntegrityError(
135
+ "archive_metadata_invalid",
136
+ "sandbox rematerialization selected no durable archive revision",
137
+ );
138
+ }
139
+ rematerialization = { id, selectedRevision };
140
+ } else if (input.acquiredLease.recovery.archive.status !== "none") {
141
+ throw new SandboxLeaseRecoveryBlockedError(
142
+ input.sandboxGroupId,
143
+ input.expectedEpoch,
144
+ "restore_degraded",
145
+ input.acquiredLease.recovery,
146
+ );
147
+ }
148
+
149
+ established = await establishSandboxSessionFromEnvelope(input.settings, spawnEnvelope, {
150
+ sessionId: input.sessionId,
151
+ recovery: "create-or-restore",
152
+ backendOverride: input.backend as never,
153
+ environment: input.environment,
154
+ onSandboxCreated: async (created) => {
155
+ established = created;
156
+ const resumeState = requirePersistableReplacementSandboxEnvelope(
157
+ await serializeReplacementSandboxEnvelope(created, spawnEnvelope),
158
+ created.backendId,
159
+ );
160
+ const recorded = await recordWarmingSandboxCreated(input.db, {
161
+ accountId: input.accountId,
162
+ workspaceId: input.workspaceId,
163
+ sandboxGroupId: input.sandboxGroupId,
164
+ expectedEpoch: input.expectedEpoch,
165
+ rematerializationId: rematerialization?.id ?? null,
166
+ instanceId: created.instanceId,
167
+ resumeBackendId: created.backendId,
168
+ resumeState,
169
+ leaseTtlMs: input.settings.sandboxLeaseTtlMs,
170
+ warmingLeaseTtlMs: input.settings.sandboxWarmingTimeoutMs,
171
+ });
172
+ if (!recorded.recorded) {
173
+ throw new SandboxLeaseSupersededError(input.sandboxGroupId, input.expectedEpoch);
174
+ }
175
+ if (created.backendId === "modal") {
176
+ await tagModalSandbox(input.settings, created.instanceId, {
177
+ leaseId: input.acquiredLease.id,
178
+ workspaceId: input.workspaceId,
179
+ sandboxGroupId: input.sandboxGroupId,
180
+ }).catch(() => undefined);
181
+ }
182
+ },
183
+ onWorkspaceRestoreVerifying: async (descriptor: WorkspaceArchiveDescriptor) => {
184
+ if (!rematerialization || descriptor.revision !== rematerialization.selectedRevision) {
185
+ throw new WorkspaceArchiveIntegrityError(
186
+ "archive_metadata_invalid",
187
+ `hydrated archive revision ${descriptor.revision} does not match the selected rematerialization revision`,
188
+ );
189
+ }
190
+ const verifying = await markSandboxRestoreVerifying(input.db, {
191
+ accountId: input.accountId,
192
+ workspaceId: input.workspaceId,
193
+ sandboxGroupId: input.sandboxGroupId,
194
+ expectedEpoch: input.expectedEpoch,
195
+ rematerializationId: rematerialization.id,
196
+ });
197
+ if (!verifying.wrote) {
198
+ throw new SandboxLeaseSupersededError(input.sandboxGroupId, input.expectedEpoch);
199
+ }
200
+ },
201
+ });
202
+
203
+ await verifySandboxExecReadiness(established);
204
+ if (
205
+ rematerialization &&
206
+ established.restoredArchive?.revision !== rematerialization.selectedRevision
207
+ ) {
208
+ throw new WorkspaceArchiveIntegrityError(
209
+ "workspace_fingerprint_mismatch",
210
+ "sandbox restore completed without the exact selected durable archive revision",
211
+ );
212
+ }
213
+ const resumeState = requirePersistableReplacementSandboxEnvelope(
214
+ await serializeReplacementSandboxEnvelope(established, spawnEnvelope),
215
+ established.backendId,
216
+ );
217
+ const committed = await commitWarmingToWarm(input.db, {
218
+ accountId: input.accountId,
219
+ workspaceId: input.workspaceId,
220
+ sandboxGroupId: input.sandboxGroupId,
221
+ expectedEpoch: input.expectedEpoch,
222
+ instanceId: established.instanceId,
223
+ dataPlaneUrl: input.dataPlaneUrl,
224
+ resumeBackendId: established.backendId,
225
+ resumeState,
226
+ ...(rematerialization
227
+ ? {
228
+ rematerialization: {
229
+ id: rematerialization.id,
230
+ verifiedRevision: rematerialization.selectedRevision,
231
+ },
232
+ }
233
+ : {}),
234
+ leaseTtlMs: input.settings.sandboxLeaseTtlMs,
235
+ });
236
+ if (!committed.committed || !committed.lease) {
237
+ const terminated = await terminateCreated(established);
238
+ if (terminated && rematerialization) {
239
+ await failSandboxRematerialization(input.db, {
240
+ accountId: input.accountId,
241
+ workspaceId: input.workspaceId,
242
+ sandboxGroupId: input.sandboxGroupId,
243
+ expectedEpoch: input.expectedEpoch,
244
+ rematerializationId: rematerialization.id,
245
+ failureCode: committed.reason ?? "warm_commit_rejected",
246
+ retryable: false,
247
+ });
248
+ } else if (terminated) {
249
+ await failWarmingToCold(input.db, {
250
+ accountId: input.accountId,
251
+ workspaceId: input.workspaceId,
252
+ sandboxGroupId: input.sandboxGroupId,
253
+ expectedEpoch: input.expectedEpoch,
254
+ });
255
+ }
256
+ throw new SandboxLeaseSupersededError(input.sandboxGroupId, input.expectedEpoch);
257
+ }
258
+ return { established, lease: committed.lease };
259
+ } catch (error) {
260
+ if (error instanceof SandboxLeaseSupersededError) throw error;
261
+ const terminated = await terminateCreated(established);
262
+ if (terminated) {
263
+ if (rematerialization) {
264
+ await failSandboxRematerialization(input.db, {
265
+ accountId: input.accountId,
266
+ workspaceId: input.workspaceId,
267
+ sandboxGroupId: input.sandboxGroupId,
268
+ expectedEpoch: input.expectedEpoch,
269
+ rematerializationId: rematerialization.id,
270
+ failureCode:
271
+ error instanceof WorkspaceArchiveIntegrityError
272
+ ? error.code
273
+ : "sandbox_rematerialization_failed",
274
+ retryable: error instanceof WorkspaceArchiveIntegrityError ? error.retryable : true,
275
+ });
276
+ } else {
277
+ await failWarmingToCold(input.db, {
278
+ accountId: input.accountId,
279
+ workspaceId: input.workspaceId,
280
+ sandboxGroupId: input.sandboxGroupId,
281
+ expectedEpoch: input.expectedEpoch,
282
+ });
283
+ }
284
+ }
285
+ throw error;
286
+ }
287
+ }