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