@opengeni/core 0.4.6 → 0.4.7

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.
@@ -15,7 +15,6 @@ import type { Settings } from "@opengeni/config";
15
15
  import {
16
16
  getEnrollment,
17
17
  getSandbox,
18
- listEnrollments,
19
18
  listSandboxes,
20
19
  readActiveSandbox,
21
20
  requireSession,
@@ -29,6 +28,8 @@ import {
29
28
  NatsControlRpc,
30
29
  selfhostedLiveness,
31
30
  SelfhostedSession,
31
+ swapTargetEstablishability,
32
+ type BackendUnresolvableCode,
32
33
  type ControlRpc,
33
34
  type NatsRequestConnection,
34
35
  } from "@opengeni/runtime/sandbox";
@@ -119,12 +120,14 @@ export type FleetListResult = {
119
120
  sandboxes: FleetSandboxEntry[];
120
121
  };
121
122
 
122
- /** A swap/attach outcome the tool returns. */
123
+ /** A swap/attach outcome the tool returns. On a rejection, `code` carries the
124
+ * typed reason (issue #341 typed diagnostics) alongside the human `reason`. */
123
125
  export type FleetSwapResult = {
124
126
  swapped: boolean;
125
127
  activeSandboxId: string | null;
126
128
  activeEpoch: number;
127
129
  reason?: string;
130
+ code?: BackendUnresolvableCode | "concurrent_swap";
128
131
  };
129
132
 
130
133
  const PROBE_TIMEOUT_MS = 5_000;
@@ -171,6 +174,8 @@ async function probeEnrollment(
171
174
  allowScreenControl: enrollment.allowScreenControl,
172
175
  hasDisplay: enrollment.hasDisplay,
173
176
  lastSeenAt: enrollment.lastSeenAt,
177
+ wentOfflineAt: enrollment.wentOfflineAt,
178
+ wentOfflineReason: enrollment.wentOfflineReason,
174
179
  },
175
180
  probeResponded,
176
181
  });
@@ -182,7 +187,10 @@ async function probeEnrollment(
182
187
  * workspace's first-class selfhosted sandboxes (each probed for liveness), each
183
188
  * with an `active` marker derived from the session's active pointer.
184
189
  */
185
- export async function listFleet(services: FleetServices, ctx: FleetContext): Promise<FleetListResult> {
190
+ export async function listFleet(
191
+ services: FleetServices,
192
+ ctx: FleetContext,
193
+ ): Promise<FleetListResult> {
186
194
  const { db } = services;
187
195
  const pointer = (await readActiveSandbox(db, ctx.workspaceId, ctx.sessionId)) ?? {
188
196
  activeSandboxId: null,
@@ -231,7 +239,11 @@ export async function listFleet(services: FleetServices, ctx: FleetContext): Pro
231
239
  });
232
240
  }
233
241
 
234
- return { activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch, sandboxes: entries };
242
+ return {
243
+ activeSandboxId: pointer.activeSandboxId,
244
+ activeEpoch: pointer.activeEpoch,
245
+ sandboxes: entries,
246
+ };
235
247
  }
236
248
 
237
249
  /** Resolve a swap target id → the value `setActiveSandbox` writes. The session's
@@ -241,26 +253,59 @@ async function resolveTarget(
241
253
  services: FleetServices,
242
254
  ctx: FleetContext,
243
255
  target: string,
244
- ): Promise<{ ok: true; targetSandboxId: string | null } | { ok: false; reason: string }> {
256
+ ): Promise<
257
+ | { ok: true; targetSandboxId: string | null }
258
+ | { ok: false; reason: string; code: BackendUnresolvableCode }
259
+ > {
245
260
  // The session's own group box → the default pointer (null).
246
261
  if (target === ctx.sessionGroupId || target === "session" || target === "default") {
247
262
  return { ok: true, targetSandboxId: null };
248
263
  }
249
264
  const sandbox = await getSandbox(services.db, ctx.workspaceId, target);
250
265
  if (!sandbox) {
251
- return { ok: false, reason: `sandbox ${target} not found in this workspace` };
266
+ return {
267
+ ok: false,
268
+ reason: `sandbox ${target} not found in this workspace`,
269
+ code: "stale_pointer",
270
+ };
271
+ }
272
+ // ESTABLISHER-CAPABILITY GATE (issue #341 invariant A): a target must be
273
+ // establishable by a turn's routing context BEFORE the epoch-fenced CAS commits
274
+ // the pointer, or a "successful" swap strands every following op on a backend no
275
+ // turn can resume. `swapTargetEstablishability` is the SAME predicate the turn
276
+ // resolver consults, so admission and establishment never disagree. Any sandbox
277
+ // reaching here is NOT the session's own group box (handled above), so a Modal
278
+ // sibling is rejected pre-commit rather than admitted-then-stranded.
279
+ const establishable = swapTargetEstablishability({
280
+ kind: sandbox.kind,
281
+ isSessionGroup: false,
282
+ });
283
+ if (!establishable.ok) {
284
+ return { ok: false, reason: establishable.reason, code: establishable.code };
252
285
  }
253
286
  if (sandbox.kind === "selfhosted") {
254
287
  if (!sandbox.enrollmentId) {
255
- return { ok: false, reason: `selfhosted sandbox ${target} has no enrollment` };
288
+ return {
289
+ ok: false,
290
+ reason: `selfhosted sandbox ${target} has no enrollment`,
291
+ code: "offline_enrollment",
292
+ };
256
293
  }
257
294
  const enrollment = await getEnrollment(services.db, ctx.workspaceId, sandbox.enrollmentId);
258
295
  if (!enrollment) {
259
- return { ok: false, reason: `enrollment for sandbox ${target} not found` };
296
+ return {
297
+ ok: false,
298
+ reason: `enrollment for sandbox ${target} not found`,
299
+ code: "offline_enrollment",
300
+ };
260
301
  }
261
302
  const probe = await probeEnrollment(services, ctx.workspaceId, enrollment);
262
303
  if (probe.liveness !== "online") {
263
- return { ok: false, reason: `sandbox ${target} is ${probe.liveness}; cannot attach to a non-online machine` };
304
+ return {
305
+ ok: false,
306
+ reason: `sandbox ${target} is ${probe.liveness}; cannot attach to a non-online machine`,
307
+ code: "offline_enrollment",
308
+ };
264
309
  }
265
310
  }
266
311
  return { ok: true, targetSandboxId: sandbox.id };
@@ -289,7 +334,15 @@ export async function swapActiveSandbox(
289
334
  activeSandboxId: null,
290
335
  activeEpoch: 0,
291
336
  };
292
- return { swapped: false, activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch, reason: resolved.reason };
337
+ // Fail BEFORE the CAS: the pointer + epoch are read back unchanged and echoed,
338
+ // so an unestablishable target never mutates the session's routing state.
339
+ return {
340
+ swapped: false,
341
+ activeSandboxId: pointer.activeSandboxId,
342
+ activeEpoch: pointer.activeEpoch,
343
+ reason: resolved.reason,
344
+ code: resolved.code,
345
+ };
293
346
  }
294
347
 
295
348
  // Read the current epoch, then CAS on it (the fence). One retry on a lost race
@@ -301,7 +354,11 @@ export async function swapActiveSandbox(
301
354
  };
302
355
  // No-op swap (already pointed there) is a success without an epoch bump churn.
303
356
  if (pointer.activeSandboxId === resolved.targetSandboxId) {
304
- return { swapped: true, activeSandboxId: pointer.activeSandboxId, activeEpoch: pointer.activeEpoch };
357
+ return {
358
+ swapped: true,
359
+ activeSandboxId: pointer.activeSandboxId,
360
+ activeEpoch: pointer.activeEpoch,
361
+ };
305
362
  }
306
363
  const result = await setActiveSandbox(services.db, {
307
364
  accountId: ctx.accountId,
@@ -312,7 +369,11 @@ export async function swapActiveSandbox(
312
369
  ...(workingDir !== undefined ? { workingDir } : {}),
313
370
  });
314
371
  if (result.swapped && result.pointer) {
315
- return { swapped: true, activeSandboxId: result.pointer.activeSandboxId, activeEpoch: result.pointer.activeEpoch };
372
+ return {
373
+ swapped: true,
374
+ activeSandboxId: result.pointer.activeSandboxId,
375
+ activeEpoch: result.pointer.activeEpoch,
376
+ };
316
377
  }
317
378
  // CAS lost (a concurrent swap won) — re-read + retry once.
318
379
  }
@@ -325,6 +386,7 @@ export async function swapActiveSandbox(
325
386
  activeSandboxId: pointer.activeSandboxId,
326
387
  activeEpoch: pointer.activeEpoch,
327
388
  reason: "a concurrent swap won the epoch fence; re-read and retry",
389
+ code: "concurrent_swap",
328
390
  };
329
391
  }
330
392
 
@@ -362,7 +424,12 @@ export async function runOnSandbox(
362
424
  ): Promise<RunOnResult> {
363
425
  const sandbox = await getSandbox(services.db, ctx.workspaceId, target);
364
426
  if (!sandbox) {
365
- return { target, kind: op.kind, ok: false, reason: `sandbox ${target} not found in this workspace` };
427
+ return {
428
+ target,
429
+ kind: op.kind,
430
+ ok: false,
431
+ reason: `sandbox ${target} not found in this workspace`,
432
+ };
366
433
  }
367
434
  if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
368
435
  return {
@@ -386,8 +453,18 @@ export async function runOnSandbox(
386
453
 
387
454
  try {
388
455
  if (op.kind === "exec") {
389
- const res = await session.exec({ cmd: op.cmd, ...(op.workdir ? { workdir: op.workdir } : {}) });
390
- return { target, kind: "exec", ok: true, stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode };
456
+ const res = await session.exec({
457
+ cmd: op.cmd,
458
+ ...(op.workdir ? { workdir: op.workdir } : {}),
459
+ });
460
+ return {
461
+ target,
462
+ kind: "exec",
463
+ ok: true,
464
+ stdout: res.stdout,
465
+ stderr: res.stderr,
466
+ exitCode: res.exitCode,
467
+ };
391
468
  }
392
469
  if (op.kind === "read") {
393
470
  const bytes = await session.readFile({ path: op.path });
@@ -443,8 +520,10 @@ export async function provisionSandbox(
443
520
  note: "Whole-machine access requires explicit human consent in the device-flow web page; the agent cannot self-consent.",
444
521
  };
445
522
  }
446
- // modal: create a first-class named modal sandbox record (a swap target). The
447
- // box is materialized lazily on first swap (Modal lifecycle unchanged).
523
+ // modal: create a first-class named modal sandbox record. NOTE: a session cannot
524
+ // yet be swapped onto a second Modal box — cross-group Modal routing is not built,
525
+ // so `sandbox_swap` to this id is rejected (unsupported_backend_context). The
526
+ // response says so plainly rather than implying an attach that does not work.
448
527
  const { createSandbox } = await import("@opengeni/db");
449
528
  const sandbox = await createSandbox(services.db, {
450
529
  accountId: ctx.accountId,
@@ -455,6 +534,6 @@ export async function provisionSandbox(
455
534
  return {
456
535
  kind: "modal",
457
536
  sandbox,
458
- note: "A named Modal sandbox record was created. Its box is materialized when first swapped-to; the session's own group box remains the default until then.",
537
+ note: "A named Modal sandbox record was created, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so a sandbox_swap to this id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
459
538
  };
460
539
  }
@@ -108,7 +108,12 @@ export function wrapChannelABoxWithRouting(
108
108
  getSandbox: async (sandboxId): Promise<RoutableSandbox | null> => {
109
109
  const sandbox = await getSandbox(db, ids.workspaceId, sandboxId);
110
110
  return sandbox
111
- ? { id: sandbox.id, kind: sandbox.kind, name: sandbox.name, enrollmentId: sandbox.enrollmentId }
111
+ ? {
112
+ id: sandbox.id,
113
+ kind: sandbox.kind,
114
+ name: sandbox.name,
115
+ enrollmentId: sandbox.enrollmentId,
116
+ }
112
117
  : null;
113
118
  },
114
119
  controlRpcFactory: controlRpcFactory(bus),
@@ -21,8 +21,20 @@
21
21
  export type ApiSandboxSession = {
22
22
  state?: Record<string, unknown> & { sandboxId?: string };
23
23
  running?(): Promise<boolean>;
24
- exec?(args: { cmd: string; workdir?: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }): Promise<unknown>;
25
- execCommand?(args: { cmd: string; workdir?: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }): Promise<string>;
24
+ exec?(args: {
25
+ cmd: string;
26
+ workdir?: string;
27
+ runAs?: string;
28
+ yieldTimeMs?: number;
29
+ maxOutputTokens?: number;
30
+ }): Promise<unknown>;
31
+ execCommand?(args: {
32
+ cmd: string;
33
+ workdir?: string;
34
+ runAs?: string;
35
+ yieldTimeMs?: number;
36
+ maxOutputTokens?: number;
37
+ }): Promise<string>;
26
38
  shutdown?(options?: unknown): Promise<void>;
27
39
  delete?(options?: unknown): Promise<void>;
28
40
  close?(): Promise<void>;
@@ -30,9 +42,9 @@ export type ApiSandboxSession = {
30
42
 
31
43
  export type ApiSandboxClient = {
32
44
  backendId: string;
33
- deserializeSessionState?(state: Record<string, unknown>): Promise<unknown>;
34
- resume?(state: unknown, options?: unknown): Promise<ApiSandboxSession>;
35
- delete?(state: unknown): Promise<void>;
45
+ deserializeSessionState?(state: Record<string, unknown>): Promise<Record<string, unknown>>;
46
+ resume?(state: Record<string, unknown>, options?: unknown): Promise<ApiSandboxSession>;
47
+ delete?(state: Record<string, unknown>): Promise<void>;
36
48
  };
37
49
 
38
50
  export type ResumeBoxByIdInput = {