@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.
- package/dist/index.d.ts +179 -30
- package/dist/index.js +1318 -475
- package/dist/index.js.map +1 -1
- package/package.json +17 -17
- package/src/access/index.ts +132 -57
- package/src/billing/limits.ts +76 -34
- package/src/dependencies.ts +76 -14
- package/src/domain/capabilities.ts +380 -181
- package/src/domain/environments.ts +58 -38
- package/src/domain/packs.ts +49 -16
- package/src/domain/resources.ts +71 -28
- package/src/domain/scheduled-tasks.ts +107 -41
- package/src/domain/sessions.ts +514 -258
- package/src/domain/workspace-members.ts +6 -2
- package/src/index.ts +1 -0
- package/src/rigs/index.ts +540 -0
- package/src/sandbox/fleet.ts +97 -18
- package/src/sandbox/routing.ts +6 -1
- package/src/sandbox-types.ts +17 -5
package/src/sandbox/fleet.ts
CHANGED
|
@@ -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(
|
|
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 {
|
|
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<
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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({
|
|
390
|
-
|
|
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
|
|
447
|
-
//
|
|
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
|
|
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
|
}
|
package/src/sandbox/routing.ts
CHANGED
|
@@ -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
|
-
? {
|
|
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),
|
package/src/sandbox-types.ts
CHANGED
|
@@ -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: {
|
|
25
|
-
|
|
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:
|
|
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 = {
|