@opengeni/core 0.11.8 → 0.12.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.
@@ -2,11 +2,10 @@
2
2
  // real DB pointer + the live NATS control plane for the API-DIRECT Channel-A path
3
3
  // (M7). Symmetric with apps/worker/src/sandbox-routing.ts (the turn path).
4
4
  //
5
- // A Channel-A op resumes the group box by id and runs ONE op against it. With
6
- // hot-swap, the op must land on the session's CURRENTLY-active sandbox, not
7
- // always the group box: if the session swapped to a selfhosted machine, an
8
- // fs.read / git.status / exec from the API must reach THAT machine. So the
9
- // established group session is wrapped in a `RoutingSandboxSession` that re-reads
5
+ // A Channel-A op runs against one established home: either a lease-owned provider
6
+ // box or a Connected Machine home that intentionally has no cloud lease. With
7
+ // hot-swap, the op must land on the session's CURRENTLY-active sandbox. The
8
+ // established session is wrapped in a `RoutingSandboxSession` that re-reads
10
9
  // (active_sandbox_id, active_epoch) and dispatches to the active backend.
11
10
  //
12
11
  // The DB-coupled glue (readActiveSandbox / getSandbox / the selfhosted ControlRpc
@@ -112,11 +111,10 @@ export function routingEnabled(settings: Settings): boolean {
112
111
  }
113
112
 
114
113
  /**
115
- * Wrap an established group-box session in a `RoutingSandboxSession` so a
116
- * Channel-A op routes to the session's currently-active sandbox. Returns the
117
- * established handle with its `session` replaced by the stable proxy. With the
118
- * default pointer (active_sandbox_id == null) this routes to the group box
119
- * unchanged; a selfhosted active pointer routes the op to the machine.
114
+ * Wrap an established home session in a `RoutingSandboxSession` so a Channel-A
115
+ * op routes to the session's currently-active sandbox. Provider homes supply a
116
+ * lease; machine homes supply a pinned selfhosted identity and no lease. Returns
117
+ * the established handle with its `session` replaced by the stable proxy.
120
118
  */
121
119
  export function wrapChannelABoxWithRouting(
122
120
  services: ChannelARoutingServices,
@@ -124,12 +122,19 @@ export function wrapChannelABoxWithRouting(
124
122
  accountId: string;
125
123
  workspaceId: string;
126
124
  sessionId: string;
127
- homeLease: {
125
+ homeLease?: {
128
126
  sandboxGroupId: string;
129
127
  leaseEpoch: number;
130
128
  instanceId: string;
131
129
  backend: string;
132
130
  };
131
+ /** A machine-home Channel-A request has no cloud/home lease. Its already
132
+ * established SelfhostedSession is pinned to the durable active pointer so
133
+ * the first command uses the exact machine instance and epoch. */
134
+ pinnedSelfhosted?: {
135
+ sandboxId: string;
136
+ epoch: number;
137
+ };
133
138
  directRequest: {
134
139
  requestId: string;
135
140
  holderId: string;
@@ -138,199 +143,212 @@ export function wrapChannelABoxWithRouting(
138
143
  established: EstablishedSandboxSession,
139
144
  ): EstablishedSandboxSession {
140
145
  const { db, settings, bus } = services;
141
- const beforeMutation = async ({
142
- op,
143
- backend,
144
- }: {
145
- op: string;
146
- backend: ResolvedActiveBackend;
147
- }): Promise<SandboxWorkspaceMutationAdmission | null> => {
148
- // Connected Machines and other non-persistable targets intentionally do
149
- // not dirty or advance the cloud-home archive generation.
150
- if (
151
- backend.sandboxId !== null ||
152
- backend.leaseEpoch === undefined ||
153
- backend.providerInstanceId === undefined
154
- ) {
155
- return null;
156
- }
157
- if (backend.activeEpoch === undefined) {
158
- throw new Error("API-direct workspace mutation resolved without an active route epoch");
159
- }
160
- return await advanceWorkspaceGenerationForDirectRequest(db, {
161
- accountId: ids.accountId,
162
- workspaceId: ids.workspaceId,
163
- sessionId: ids.sessionId,
164
- requestId: ids.directRequest.requestId,
165
- holderId: ids.directRequest.holderId,
166
- sandboxGroupId: ids.homeLease.sandboxGroupId,
167
- expectedEpoch: backend.leaseEpoch,
168
- expectedInstanceId: backend.providerInstanceId,
169
- routeTargetId: backend.sandboxId,
170
- routeEpoch: backend.activeEpoch,
171
- operation: op,
172
- });
173
- };
174
- const afterMutation = async ({
175
- op,
176
- backend,
177
- admission,
178
- outcome,
179
- retainedProcess,
180
- }: {
181
- op: string;
182
- backend: ResolvedActiveBackend;
183
- admission: unknown;
184
- outcome: "resolved" | "rejected";
185
- result?: unknown;
186
- retainedProcess?: RoutingRetainedProcess;
187
- }): Promise<void> => {
188
- if (admission === null) return;
189
- if (
190
- !admission ||
191
- typeof admission !== "object" ||
192
- typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
193
- typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
194
- "number" ||
195
- backend.leaseEpoch === undefined ||
196
- backend.providerInstanceId === undefined ||
197
- backend.activeEpoch === undefined
198
- ) {
199
- throw new Error("API-direct workspace mutation settlement lacked its exact admission");
200
- }
201
- const exactAdmission = admission as SandboxWorkspaceMutationAdmission;
202
- if (outcome === "resolved" && retainedProcess) {
203
- await retainWorkspaceMutationProcess(db, {
204
- accountId: ids.accountId,
205
- workspaceId: ids.workspaceId,
206
- sessionId: ids.sessionId,
207
- processId: retainedProcess.id,
208
- providerSessionId: retainedProcess.providerSessionId,
209
- admissionId: exactAdmission.id,
210
- admittedWorkspaceGeneration: exactAdmission.workspaceGeneration,
211
- operation: op,
212
- owner: {
213
- kind: "direct",
146
+ const homeLease = ids.homeLease;
147
+ const beforeMutation = homeLease
148
+ ? async ({
149
+ op,
150
+ backend,
151
+ }: {
152
+ op: string;
153
+ backend: ResolvedActiveBackend;
154
+ }): Promise<SandboxWorkspaceMutationAdmission | null> => {
155
+ // Connected Machines and other non-persistable targets intentionally do
156
+ // not dirty or advance the cloud-home archive generation.
157
+ if (
158
+ backend.sandboxId !== null ||
159
+ backend.leaseEpoch === undefined ||
160
+ backend.providerInstanceId === undefined
161
+ ) {
162
+ return null;
163
+ }
164
+ if (backend.activeEpoch === undefined) {
165
+ throw new Error("API-direct workspace mutation resolved without an active route epoch");
166
+ }
167
+ return await advanceWorkspaceGenerationForDirectRequest(db, {
168
+ accountId: ids.accountId,
169
+ workspaceId: ids.workspaceId,
170
+ sessionId: ids.sessionId,
171
+ requestId: ids.directRequest.requestId,
172
+ holderId: ids.directRequest.holderId,
173
+ sandboxGroupId: homeLease.sandboxGroupId,
174
+ expectedEpoch: backend.leaseEpoch,
175
+ expectedInstanceId: backend.providerInstanceId,
176
+ routeTargetId: backend.sandboxId,
177
+ routeEpoch: backend.activeEpoch,
178
+ operation: op,
179
+ });
180
+ }
181
+ : undefined;
182
+ const afterMutation = homeLease
183
+ ? async ({
184
+ op,
185
+ backend,
186
+ admission,
187
+ outcome,
188
+ retainedProcess,
189
+ }: {
190
+ op: string;
191
+ backend: ResolvedActiveBackend;
192
+ admission: unknown;
193
+ outcome: "resolved" | "rejected";
194
+ result?: unknown;
195
+ retainedProcess?: RoutingRetainedProcess;
196
+ }): Promise<void> => {
197
+ if (admission === null) return;
198
+ if (
199
+ !admission ||
200
+ typeof admission !== "object" ||
201
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
202
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
203
+ "number" ||
204
+ backend.leaseEpoch === undefined ||
205
+ backend.providerInstanceId === undefined ||
206
+ backend.activeEpoch === undefined
207
+ ) {
208
+ throw new Error("API-direct workspace mutation settlement lacked its exact admission");
209
+ }
210
+ const exactAdmission = admission as SandboxWorkspaceMutationAdmission;
211
+ if (outcome === "resolved" && retainedProcess) {
212
+ await retainWorkspaceMutationProcess(db, {
213
+ accountId: ids.accountId,
214
+ workspaceId: ids.workspaceId,
215
+ sessionId: ids.sessionId,
216
+ processId: retainedProcess.id,
217
+ providerSessionId: retainedProcess.providerSessionId,
218
+ admissionId: exactAdmission.id,
219
+ admittedWorkspaceGeneration: exactAdmission.workspaceGeneration,
220
+ operation: op,
221
+ owner: {
222
+ kind: "direct",
223
+ requestId: ids.directRequest.requestId,
224
+ holderId: ids.directRequest.holderId,
225
+ sandboxGroupId: homeLease.sandboxGroupId,
226
+ expectedEpoch: backend.leaseEpoch,
227
+ expectedInstanceId: backend.providerInstanceId,
228
+ routeTargetId: exactAdmission.routeTargetId,
229
+ routeEpoch: exactAdmission.routeEpoch,
230
+ },
231
+ });
232
+ return;
233
+ }
234
+ await verifyDirectWorkspaceMutationSettlement(db, {
235
+ accountId: ids.accountId,
236
+ workspaceId: ids.workspaceId,
237
+ sessionId: ids.sessionId,
214
238
  requestId: ids.directRequest.requestId,
215
239
  holderId: ids.directRequest.holderId,
216
- sandboxGroupId: ids.homeLease.sandboxGroupId,
240
+ sandboxGroupId: homeLease.sandboxGroupId,
217
241
  expectedEpoch: backend.leaseEpoch,
218
242
  expectedInstanceId: backend.providerInstanceId,
219
243
  routeTargetId: exactAdmission.routeTargetId,
220
244
  routeEpoch: exactAdmission.routeEpoch,
221
- },
222
- });
223
- return;
224
- }
225
- await verifyDirectWorkspaceMutationSettlement(db, {
226
- accountId: ids.accountId,
227
- workspaceId: ids.workspaceId,
228
- sessionId: ids.sessionId,
229
- requestId: ids.directRequest.requestId,
230
- holderId: ids.directRequest.holderId,
231
- sandboxGroupId: ids.homeLease.sandboxGroupId,
232
- expectedEpoch: backend.leaseEpoch,
233
- expectedInstanceId: backend.providerInstanceId,
234
- routeTargetId: exactAdmission.routeTargetId,
235
- routeEpoch: exactAdmission.routeEpoch,
236
- admission: exactAdmission,
237
- operation: op,
238
- outcome,
239
- });
240
- };
241
- const beforeProcessMutation = async ({
242
- op,
243
- process,
244
- }: {
245
- op: string;
246
- backend: ResolvedActiveBackend;
247
- process: RoutingRetainedProcess;
248
- }): Promise<SandboxWorkspaceMutationAdmission> =>
249
- await advanceWorkspaceGenerationForRetainedProcess(db, {
250
- accountId: ids.accountId,
251
- workspaceId: ids.workspaceId,
252
- sessionId: ids.sessionId,
253
- processId: process.id,
254
- operation: op,
255
- });
256
- const afterProcessMutation = async ({
257
- op,
258
- process,
259
- admission,
260
- outcome,
261
- }: {
262
- op: string;
263
- backend: ResolvedActiveBackend;
264
- process: RoutingRetainedProcess;
265
- admission: unknown;
266
- outcome: "resolved" | "rejected";
267
- result?: unknown;
268
- }): Promise<void> => {
269
- if (
270
- !admission ||
271
- typeof admission !== "object" ||
272
- typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
273
- typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
274
- "number"
275
- ) {
276
- throw new Error("API retained-process mutation settlement lacked its exact admission");
277
- }
278
- await verifyRetainedProcessMutationSettlement(db, {
279
- accountId: ids.accountId,
280
- workspaceId: ids.workspaceId,
281
- sessionId: ids.sessionId,
282
- processId: process.id,
283
- admission: admission as SandboxWorkspaceMutationAdmission,
284
- operation: op,
285
- outcome,
286
- });
287
- };
288
- const settleProcess = async ({
289
- backend,
290
- process,
291
- proof,
292
- }: {
293
- backend: ResolvedActiveBackend;
294
- process: RoutingRetainedProcess;
295
- proof: RoutingRetainedProcessTerminalProof;
296
- }): Promise<void> => {
297
- if (
298
- backend.sandboxId !== null ||
299
- backend.leaseEpoch === undefined ||
300
- backend.providerInstanceId === undefined ||
301
- backend.activeEpoch === undefined
302
- ) {
303
- return;
304
- }
305
- const durable = await getRetainedProcess(db, {
306
- workspaceId: ids.workspaceId,
307
- sessionId: ids.sessionId,
308
- processId: process.id,
309
- });
310
- if (
311
- !durable ||
312
- durable.providerSessionId !== process.providerSessionId ||
313
- durable.providerBackend !== backend.kind ||
314
- durable.providerInstanceId !== backend.providerInstanceId ||
315
- durable.leaseEpoch !== backend.leaseEpoch ||
316
- durable.routeKind !== (backend.sandboxId === null ? "home" : "active") ||
317
- durable.routeTargetId !== backend.sandboxId ||
318
- durable.routeEpoch !== backend.activeEpoch
319
- ) {
320
- throw new Error("API retained-process settlement lost its exact durable backend identity");
321
- }
322
- await settleRetainedProcess(db, {
323
- accountId: ids.accountId,
324
- workspaceId: ids.workspaceId,
325
- sessionId: ids.sessionId,
326
- processId: process.id,
327
- expected: retainedProcessSettlementIdentity(durable),
328
- outcome: proof.outcome,
329
- exitCode: proof.exitCode,
330
- reason: proof.reason,
331
- idleGraceMs: settings.sandboxIdleGraceMs,
332
- });
333
- };
245
+ admission: exactAdmission,
246
+ operation: op,
247
+ outcome,
248
+ });
249
+ }
250
+ : undefined;
251
+ const beforeProcessMutation = homeLease
252
+ ? async ({
253
+ op,
254
+ process,
255
+ }: {
256
+ op: string;
257
+ backend: ResolvedActiveBackend;
258
+ process: RoutingRetainedProcess;
259
+ }): Promise<SandboxWorkspaceMutationAdmission> =>
260
+ await advanceWorkspaceGenerationForRetainedProcess(db, {
261
+ accountId: ids.accountId,
262
+ workspaceId: ids.workspaceId,
263
+ sessionId: ids.sessionId,
264
+ processId: process.id,
265
+ operation: op,
266
+ })
267
+ : undefined;
268
+ const afterProcessMutation = homeLease
269
+ ? async ({
270
+ op,
271
+ process,
272
+ admission,
273
+ outcome,
274
+ }: {
275
+ op: string;
276
+ backend: ResolvedActiveBackend;
277
+ process: RoutingRetainedProcess;
278
+ admission: unknown;
279
+ outcome: "resolved" | "rejected";
280
+ result?: unknown;
281
+ }): Promise<void> => {
282
+ if (
283
+ !admission ||
284
+ typeof admission !== "object" ||
285
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
286
+ typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
287
+ "number"
288
+ ) {
289
+ throw new Error("API retained-process mutation settlement lacked its exact admission");
290
+ }
291
+ await verifyRetainedProcessMutationSettlement(db, {
292
+ accountId: ids.accountId,
293
+ workspaceId: ids.workspaceId,
294
+ sessionId: ids.sessionId,
295
+ processId: process.id,
296
+ admission: admission as SandboxWorkspaceMutationAdmission,
297
+ operation: op,
298
+ outcome,
299
+ });
300
+ }
301
+ : undefined;
302
+ const settleProcess = homeLease
303
+ ? async ({
304
+ backend,
305
+ process,
306
+ proof,
307
+ }: {
308
+ backend: ResolvedActiveBackend;
309
+ process: RoutingRetainedProcess;
310
+ proof: RoutingRetainedProcessTerminalProof;
311
+ }): Promise<void> => {
312
+ if (
313
+ backend.sandboxId !== null ||
314
+ backend.leaseEpoch === undefined ||
315
+ backend.providerInstanceId === undefined ||
316
+ backend.activeEpoch === undefined
317
+ ) {
318
+ return;
319
+ }
320
+ const durable = await getRetainedProcess(db, {
321
+ workspaceId: ids.workspaceId,
322
+ sessionId: ids.sessionId,
323
+ processId: process.id,
324
+ });
325
+ if (
326
+ !durable ||
327
+ durable.providerSessionId !== process.providerSessionId ||
328
+ durable.providerBackend !== backend.kind ||
329
+ durable.providerInstanceId !== backend.providerInstanceId ||
330
+ durable.leaseEpoch !== backend.leaseEpoch ||
331
+ durable.routeKind !== (backend.sandboxId === null ? "home" : "active") ||
332
+ durable.routeTargetId !== backend.sandboxId ||
333
+ durable.routeEpoch !== backend.activeEpoch
334
+ ) {
335
+ throw new Error(
336
+ "API retained-process settlement lost its exact durable backend identity",
337
+ );
338
+ }
339
+ await settleRetainedProcess(db, {
340
+ accountId: ids.accountId,
341
+ workspaceId: ids.workspaceId,
342
+ sessionId: ids.sessionId,
343
+ processId: process.id,
344
+ expected: retainedProcessSettlementIdentity(durable),
345
+ outcome: proof.outcome,
346
+ exitCode: proof.exitCode,
347
+ reason: proof.reason,
348
+ idleGraceMs: settings.sandboxIdleGraceMs,
349
+ });
350
+ }
351
+ : undefined;
334
352
  const resolver = makeActiveBackendResolver({
335
353
  workspaceId: ids.workspaceId,
336
354
  defaultBackend: established.session as RoutableBackendSession,
@@ -348,13 +366,28 @@ export function wrapChannelABoxWithRouting(
348
366
  },
349
367
  controlRpcFactory: controlRpcFactory(bus),
350
368
  relay: relayConfigFromSettings(settings),
351
- resolveDefaultBackend: async () => ({
352
- session: established.session as RoutableBackendSession,
353
- sandboxId: null,
354
- kind: established.backendId,
355
- leaseEpoch: ids.homeLease.leaseEpoch,
356
- providerInstanceId: ids.homeLease.instanceId,
357
- }),
369
+ selfhostedTimeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
370
+ selfhostedExecTimeoutMs: settings.sandboxSelfhostedExecTimeoutMs,
371
+ ...(ids.pinnedSelfhosted
372
+ ? {
373
+ pinnedSelfhosted: {
374
+ sandboxId: ids.pinnedSelfhosted.sandboxId,
375
+ epoch: ids.pinnedSelfhosted.epoch,
376
+ session: established.session as RoutableBackendSession,
377
+ },
378
+ }
379
+ : {}),
380
+ ...(homeLease
381
+ ? {
382
+ resolveDefaultBackend: async () => ({
383
+ session: established.session as RoutableBackendSession,
384
+ sandboxId: null,
385
+ kind: established.backendId,
386
+ leaseEpoch: homeLease.leaseEpoch,
387
+ providerInstanceId: homeLease.instanceId,
388
+ }),
389
+ }
390
+ : {}),
358
391
  });
359
392
 
360
393
  const proxy = new RoutingSandboxSession({
@@ -362,8 +395,12 @@ export function wrapChannelABoxWithRouting(
362
395
  session: established.session as RoutableBackendSession,
363
396
  sandboxId: null,
364
397
  kind: established.backendId,
365
- leaseEpoch: ids.homeLease.leaseEpoch,
366
- providerInstanceId: ids.homeLease.instanceId,
398
+ ...(homeLease
399
+ ? {
400
+ leaseEpoch: homeLease.leaseEpoch,
401
+ providerInstanceId: homeLease.instanceId,
402
+ }
403
+ : {}),
367
404
  },
368
405
  readPointer: async () => {
369
406
  if (!routingEnabled(settings)) {
@@ -373,43 +410,47 @@ export function wrapChannelABoxWithRouting(
373
410
  return pointer ?? { activeSandboxId: null, activeEpoch: 0 };
374
411
  },
375
412
  resolveActiveBackend: resolver,
376
- beforeMutation,
377
- afterMutation,
378
- beforeProcessMutation,
379
- afterProcessMutation,
380
- settleProcess,
381
- onDefaultBackendError: async ({ error }) => {
382
- if (!isProviderSandboxGoneDuringRoutedOperation(ids.homeLease.backend, error)) return null;
383
- const marked = await markWarmLeaseInstanceLost(db, {
384
- accountId: ids.accountId,
385
- workspaceId: ids.workspaceId,
386
- sandboxGroupId: ids.homeLease.sandboxGroupId,
387
- expectedEpoch: ids.homeLease.leaseEpoch,
388
- expectedInstanceId: ids.homeLease.instanceId,
389
- diagnostic: "provider_not_found_during_routed_operation",
390
- });
391
- if (marked.status === "marked" && bus) {
392
- await appendAndPublishEvents(db, bus, ids.workspaceId, ids.sessionId, [
393
- {
394
- type: "sandbox.box.lost",
395
- payload: { sandboxId: ids.homeLease.instanceId },
413
+ ...(beforeMutation ? { beforeMutation } : {}),
414
+ ...(afterMutation ? { afterMutation } : {}),
415
+ ...(beforeProcessMutation ? { beforeProcessMutation } : {}),
416
+ ...(afterProcessMutation ? { afterProcessMutation } : {}),
417
+ ...(settleProcess ? { settleProcess } : {}),
418
+ ...(homeLease
419
+ ? {
420
+ onDefaultBackendError: async ({ error }: { error: unknown }) => {
421
+ if (!isProviderSandboxGoneDuringRoutedOperation(homeLease.backend, error)) return null;
422
+ const marked = await markWarmLeaseInstanceLost(db, {
423
+ accountId: ids.accountId,
424
+ workspaceId: ids.workspaceId,
425
+ sandboxGroupId: homeLease.sandboxGroupId,
426
+ expectedEpoch: homeLease.leaseEpoch,
427
+ expectedInstanceId: homeLease.instanceId,
428
+ diagnostic: "provider_not_found_during_routed_operation",
429
+ });
430
+ if (marked.status === "marked" && bus) {
431
+ await appendAndPublishEvents(db, bus, ids.workspaceId, ids.sessionId, [
432
+ {
433
+ type: "sandbox.box.lost",
434
+ payload: { sandboxId: homeLease.instanceId },
435
+ },
436
+ ]).catch(() => undefined);
437
+ }
438
+ const lease = marked.lease;
439
+ const restore = lease?.recovery.restore.status;
440
+ return {
441
+ leaseEpoch: lease?.leaseEpoch ?? homeLease.leaseEpoch,
442
+ recovery:
443
+ marked.status === "stale"
444
+ ? ("superseded" as const)
445
+ : restore === "pending"
446
+ ? ("pending" as const)
447
+ : restore === "degraded"
448
+ ? ("degraded" as const)
449
+ : ("unrecoverable" as const),
450
+ };
396
451
  },
397
- ]).catch(() => undefined);
398
- }
399
- const lease = marked.lease;
400
- const restore = lease?.recovery.restore.status;
401
- return {
402
- leaseEpoch: lease?.leaseEpoch ?? ids.homeLease.leaseEpoch,
403
- recovery:
404
- marked.status === "stale"
405
- ? ("superseded" as const)
406
- : restore === "pending"
407
- ? ("pending" as const)
408
- : restore === "degraded"
409
- ? ("degraded" as const)
410
- : ("unrecoverable" as const),
411
- };
412
- },
452
+ }
453
+ : {}),
413
454
  });
414
455
 
415
456
  return { ...established, session: proxy };