@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.
@@ -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;