@opengeni/core 0.2.0
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/README.md +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// apps/api/src/sandbox/routing.ts — wire the agent-loop-free routing proxy to the
|
|
2
|
+
// real DB pointer + the live NATS control plane for the API-DIRECT Channel-A path
|
|
3
|
+
// (M7). Symmetric with apps/worker/src/sandbox-routing.ts (the turn path).
|
|
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
|
|
10
|
+
// (active_sandbox_id, active_epoch) and dispatches to the active backend.
|
|
11
|
+
//
|
|
12
|
+
// The DB-coupled glue (readActiveSandbox / getSandbox / the selfhosted ControlRpc
|
|
13
|
+
// over the events bus) lives here, not in the leaf (which stays db-free).
|
|
14
|
+
|
|
15
|
+
import type { Settings } from "@opengeni/config";
|
|
16
|
+
import { getSandbox, readActiveSandbox, type Database } from "@opengeni/db";
|
|
17
|
+
import type { EventBus } from "@opengeni/events";
|
|
18
|
+
import {
|
|
19
|
+
makeActiveBackendResolver,
|
|
20
|
+
NatsControlRpc,
|
|
21
|
+
RoutingSandboxSession,
|
|
22
|
+
type ControlRpc,
|
|
23
|
+
type EstablishedSandboxSession,
|
|
24
|
+
type NatsRequestConnection,
|
|
25
|
+
type RoutableBackendSession,
|
|
26
|
+
type RoutableSandbox,
|
|
27
|
+
type SelfhostedRelayConfig,
|
|
28
|
+
} from "@opengeni/runtime/sandbox";
|
|
29
|
+
|
|
30
|
+
export type ChannelARoutingServices = {
|
|
31
|
+
db: Database;
|
|
32
|
+
settings: Settings;
|
|
33
|
+
bus?: EventBus;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Map the deployment relay URL to the leaf's `SelfhostedRelayConfig` shape. The
|
|
37
|
+
* relay URL (`OPENGENI_SELFHOSTED_RELAY_URL`) may carry a path (the relay's wss
|
|
38
|
+
* route); a path-less URL defaults to the relay's `/stream` route (M8b). */
|
|
39
|
+
export function relayConfigFromSettings(settings: Settings): SelfhostedRelayConfig {
|
|
40
|
+
const raw = settings.selfhostedRelayUrl?.trim();
|
|
41
|
+
if (!raw) {
|
|
42
|
+
return { host: "relay.opengeni.local", port: 443, tls: true, path: "/stream" };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(raw.includes("://") ? raw : `wss://${raw}`);
|
|
46
|
+
const tls = url.protocol === "wss:" || url.protocol === "https:";
|
|
47
|
+
const port = url.port ? Number(url.port) : tls ? 443 : 80;
|
|
48
|
+
// Honor an explicit path in the configured URL; default the relay's /stream.
|
|
49
|
+
const path = url.pathname && url.pathname !== "/" ? url.pathname : "/stream";
|
|
50
|
+
return { host: url.hostname, port, tls, path };
|
|
51
|
+
} catch {
|
|
52
|
+
return { host: raw, port: 443, tls: true, path: "/stream" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The canonical relay dial-BASE URL (`scheme://host[:port]/stream`) handed to the
|
|
57
|
+
* agent PRODUCER. The agent's relay channel appends ONLY its routing query to
|
|
58
|
+
* this base (`channel.rs`: `format!("{relay_url}{sep}{query}")`) and relies on the
|
|
59
|
+
* base ALREADY carrying the relay's `/stream` route. `OPENGENI_SELFHOSTED_RELAY_URL`
|
|
60
|
+
* is frequently pathless (e.g. `wss://relay.<env>.app.opengeni.ai`), which made the
|
|
61
|
+
* producer dial a path-less URL the relay 400s. Derive the base from the SAME parser
|
|
62
|
+
* the CONSUMER uses (`relayConfigFromSettings`) so producer + consumer always agree
|
|
63
|
+
* on `/stream` — even when the configured URL omits it. An unconfigured relay maps to
|
|
64
|
+
* `""` (graceful degrade: the agent reports no-relay rather than dialing a synthetic
|
|
65
|
+
* host). Fixes preview AND managed prod with no agent rebuild (dossier §V5/§V6). */
|
|
66
|
+
export function relayDialBaseFromSettings(settings: Settings): string {
|
|
67
|
+
if (!settings.selfhostedRelayUrl?.trim()) return "";
|
|
68
|
+
const { host, port, tls, path } = relayConfigFromSettings(settings);
|
|
69
|
+
const scheme = tls ? "wss" : "ws";
|
|
70
|
+
const defaultPort = tls ? 443 : 80;
|
|
71
|
+
const authority = port === defaultPort ? host : `${host}:${port}`;
|
|
72
|
+
return `${scheme}://${authority}${path}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function controlRpcFactory(bus: EventBus | undefined): () => ControlRpc {
|
|
76
|
+
return () =>
|
|
77
|
+
new NatsControlRpc(async (): Promise<NatsRequestConnection | null> => {
|
|
78
|
+
if (!bus) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return bus.getRequestConnection();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether the routing proxy should wrap the Channel-A box: gated by the
|
|
86
|
+
* selfhosted flag (the active pointer + swap are only meaningful then). */
|
|
87
|
+
export function routingEnabled(settings: Settings): boolean {
|
|
88
|
+
return settings.sandboxSelfhostedEnabled === true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Wrap an established group-box session in a `RoutingSandboxSession` so a
|
|
93
|
+
* Channel-A op routes to the session's currently-active sandbox. Returns the
|
|
94
|
+
* established handle with its `session` replaced by the stable proxy. With the
|
|
95
|
+
* default pointer (active_sandbox_id == null) this routes to the group box
|
|
96
|
+
* unchanged; a selfhosted active pointer routes the op to the machine.
|
|
97
|
+
*/
|
|
98
|
+
export function wrapChannelABoxWithRouting(
|
|
99
|
+
services: ChannelARoutingServices,
|
|
100
|
+
ids: { workspaceId: string; sessionId: string },
|
|
101
|
+
established: EstablishedSandboxSession,
|
|
102
|
+
): EstablishedSandboxSession {
|
|
103
|
+
const { db, settings, bus } = services;
|
|
104
|
+
const resolver = makeActiveBackendResolver({
|
|
105
|
+
workspaceId: ids.workspaceId,
|
|
106
|
+
defaultBackend: established.session as RoutableBackendSession,
|
|
107
|
+
defaultKind: established.backendId,
|
|
108
|
+
getSandbox: async (sandboxId): Promise<RoutableSandbox | null> => {
|
|
109
|
+
const sandbox = await getSandbox(db, ids.workspaceId, sandboxId);
|
|
110
|
+
return sandbox
|
|
111
|
+
? { id: sandbox.id, kind: sandbox.kind, name: sandbox.name, enrollmentId: sandbox.enrollmentId }
|
|
112
|
+
: null;
|
|
113
|
+
},
|
|
114
|
+
controlRpcFactory: controlRpcFactory(bus),
|
|
115
|
+
relay: relayConfigFromSettings(settings),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const proxy = new RoutingSandboxSession({
|
|
119
|
+
readPointer: async () => {
|
|
120
|
+
const pointer = await readActiveSandbox(db, ids.workspaceId, ids.sessionId);
|
|
121
|
+
return pointer ?? { activeSandboxId: null, activeEpoch: 0 };
|
|
122
|
+
},
|
|
123
|
+
resolveActiveBackend: resolver,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { ...established, session: proxy };
|
|
127
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @opengeni/core sandbox-access STRUCTURAL TYPES.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS MODULE LIVES IN CORE: `dependencies.ts` (the central deps type
|
|
4
|
+
// surface) references the API-tier sandbox-access types — `ApiSandboxClient`,
|
|
5
|
+
// `ApiSandboxSession`, `ResumeBoxByIdInput`, `ResumedSandboxSession` — only as
|
|
6
|
+
// TYPE slots (the `sandboxClient` / `resumeBoxById` provider seams). Those are
|
|
7
|
+
// PURE STRUCTURAL TYPES with no runtime dependency, so they belong in the
|
|
8
|
+
// framework-agnostic core alongside the deps types that reference them.
|
|
9
|
+
//
|
|
10
|
+
// The IMPLEMENTATION that constructs a real sandbox client —
|
|
11
|
+
// `createApiSandboxClient`, `makeResumeBoxById`, and the `SandboxResumeError`
|
|
12
|
+
// value — stays in `apps/api/src/sandbox/access.ts`, because it imports the
|
|
13
|
+
// agent-loop-free `@opengeni/runtime/sandbox` leaf via the API's single import
|
|
14
|
+
// chokepoint. apps/api's sandbox/access.ts re-imports these types from here so
|
|
15
|
+
// the structural contract has a single owner.
|
|
16
|
+
//
|
|
17
|
+
// (Mirrors @openai/agents/sandbox's SandboxClient surface without importing the
|
|
18
|
+
// agent-loop barrel — see apps/api/src/sandbox/access.ts for the import
|
|
19
|
+
// discipline that forbids the bare `@opengeni/runtime` barrel.)
|
|
20
|
+
|
|
21
|
+
export type ApiSandboxSession = {
|
|
22
|
+
state?: Record<string, unknown> & { sandboxId?: string };
|
|
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>;
|
|
26
|
+
shutdown?(options?: unknown): Promise<void>;
|
|
27
|
+
delete?(options?: unknown): Promise<void>;
|
|
28
|
+
close?(): Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ApiSandboxClient = {
|
|
32
|
+
backendId: string;
|
|
33
|
+
deserializeSessionState?(state: Record<string, unknown>): Promise<unknown>;
|
|
34
|
+
resume?(state: unknown, options?: unknown): Promise<ApiSandboxSession>;
|
|
35
|
+
delete?(state: unknown): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ResumeBoxByIdInput = {
|
|
39
|
+
/**
|
|
40
|
+
* The backend the box was created on — the lease's `resume_backend_id`. Must
|
|
41
|
+
* match the API's configured sandbox client backendId, or the resume is
|
|
42
|
+
* rejected (a cross-backend envelope can never deserialize correctly).
|
|
43
|
+
*/
|
|
44
|
+
backend: string;
|
|
45
|
+
/**
|
|
46
|
+
* The serialized resume-state envelope — the lease's `resume_state` jsonb
|
|
47
|
+
* (the record produced by `client.serializeSessionState(state)`). This is the
|
|
48
|
+
* box identity + reattach descriptor; resume() reattaches to the live box by
|
|
49
|
+
* id (warm reattach) or cold-restores from its snapshot.
|
|
50
|
+
*/
|
|
51
|
+
resumeState: Record<string, unknown>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A live, resumed sandbox session for a SINGLE in-process op. The caller
|
|
56
|
+
* resumes → uses (exec/readFile/resolvePort) → drops it; lifecycle/refcount is
|
|
57
|
+
* the lease's job (P1.x), NOT this handle's. The session is non-owned by
|
|
58
|
+
* construction (resume-by-id never owns the box), so dropping it does not
|
|
59
|
+
* terminate the box.
|
|
60
|
+
*/
|
|
61
|
+
export type ResumedSandboxSession = ApiSandboxSession;
|