@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-EYYTFA7N.js → chunk-7PQKPKW5.js} +4117 -1863
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -11
- package/src/app.ts +37 -15
- package/src/codex-redemption-security.ts +96 -0
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +151 -83
- package/src/mcp/session-view.ts +102 -1
- package/src/mcp/toolspace.ts +389 -124
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +866 -14
- package/src/routes/files.ts +153 -0
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +637 -152
- package/src/routes/workspaces.ts +36 -2
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +207 -129
- package/dist/chunk-EYYTFA7N.js.map +0 -1
package/src/routes/workspaces.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
UpdateWorkspaceRequest,
|
|
9
9
|
UpdateWorkspaceSettingsRequest,
|
|
10
10
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
11
|
+
WorkspaceModelCatalogResponse,
|
|
11
12
|
WorkspaceInferenceControlRequest,
|
|
12
13
|
Workspace,
|
|
13
14
|
WorkspaceMember,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
updateWorkspace,
|
|
36
37
|
updateWorkspaceSettings,
|
|
37
38
|
upsertWorkspaceModelPolicy,
|
|
39
|
+
workspaceCodexSubscriptionActive,
|
|
38
40
|
} from "@opengeni/db";
|
|
39
41
|
import { boundWorkspaceControlHttpPage } from "@opengeni/events";
|
|
40
42
|
import type { Hono } from "hono";
|
|
@@ -50,6 +52,18 @@ import {
|
|
|
50
52
|
} from "@opengeni/core";
|
|
51
53
|
import { boundedLimit } from "../http/common";
|
|
52
54
|
import { sseWorkspaceControlStream } from "../http/sse";
|
|
55
|
+
import { buildWorkspaceModelCatalog } from "../model-catalog";
|
|
56
|
+
import { canonicalizeConfiguredModelId, type Settings } from "@opengeni/config";
|
|
57
|
+
|
|
58
|
+
export function canonicalWorkspacePolicyModelIds(
|
|
59
|
+
settings: Settings,
|
|
60
|
+
modelIds: string[] | null | undefined,
|
|
61
|
+
): string[] | null {
|
|
62
|
+
if (modelIds === null || modelIds === undefined) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return [...new Set(modelIds.map((modelId) => canonicalizeConfiguredModelId(settings, modelId)))];
|
|
66
|
+
}
|
|
53
67
|
|
|
54
68
|
export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
55
69
|
app.get("/v1/access/me", async (c) => {
|
|
@@ -143,7 +157,27 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
143
157
|
|
|
144
158
|
// Per-workspace model/provider availability policy (the HARD blocker over
|
|
145
159
|
// which providers/models may serve a turn at all). Absent row reads as
|
|
146
|
-
// unrestricted {null, null}.
|
|
160
|
+
// unrestricted {null, null}. No Azure AD credential resolver is wired here,
|
|
161
|
+
// so bearer/federated definitions intentionally fail closed as not ready.
|
|
162
|
+
app.get("/v1/workspaces/:workspaceId/model-catalog", async (c) => {
|
|
163
|
+
const workspaceId = c.req.param("workspaceId");
|
|
164
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
165
|
+
const [policy, codexSubscriptionActive] = await Promise.all([
|
|
166
|
+
getWorkspaceModelPolicy(deps.db, workspaceId),
|
|
167
|
+
workspaceCodexSubscriptionActive(deps.db, deps.settings, workspaceId),
|
|
168
|
+
]);
|
|
169
|
+
c.header("cache-control", "private, no-store");
|
|
170
|
+
return c.json(
|
|
171
|
+
WorkspaceModelCatalogResponse.parse(
|
|
172
|
+
buildWorkspaceModelCatalog({
|
|
173
|
+
settings: deps.settings,
|
|
174
|
+
policy,
|
|
175
|
+
codexSubscriptionActive,
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
147
181
|
app.get("/v1/workspaces/:workspaceId/model-policy", async (c) => {
|
|
148
182
|
const workspaceId = c.req.param("workspaceId");
|
|
149
183
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
@@ -166,7 +200,7 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
166
200
|
accountId: grant.accountId,
|
|
167
201
|
workspaceId,
|
|
168
202
|
allowedProviders: payload.allowedProviders ?? null,
|
|
169
|
-
allowedModels: payload.allowedModels
|
|
203
|
+
allowedModels: canonicalWorkspacePolicyModelIds(deps.settings, payload.allowedModels),
|
|
170
204
|
});
|
|
171
205
|
return c.json(policy);
|
|
172
206
|
});
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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: "
|
|
115
|
-
holderId
|
|
115
|
+
kind: "direct",
|
|
116
|
+
holderId,
|
|
116
117
|
idleGraceMs: settings.sandboxIdleGraceMs,
|
|
117
118
|
});
|
|
118
119
|
};
|
|
119
120
|
|
|
120
|
-
// Acquire
|
|
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: "
|
|
126
|
-
holderId
|
|
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
|
-
|
|
196
|
+
const result = await establishApiSandboxSpawner({
|
|
197
|
+
db,
|
|
198
|
+
settings,
|
|
199
|
+
accountId,
|
|
200
|
+
workspaceId,
|
|
201
|
+
sandboxGroupId,
|
|
191
202
|
sessionId: session.id,
|
|
192
|
-
|
|
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
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 {
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -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
|
+
}
|