@opengeni/runtime 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/dist/chunk-2PO56VAL.js +3478 -0
- package/dist/chunk-2PO56VAL.js.map +1 -0
- package/dist/index.d.ts +912 -0
- package/dist/index.js +3663 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox/index.d.ts +1738 -0
- package/dist/sandbox/index.js +187 -0
- package/dist/sandbox/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
- package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
- package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
- package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
- package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
- package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
- package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
- package/src/codex-tool-search.ts +267 -0
- package/src/context-compaction.ts +538 -0
- package/src/history-sanitizer.ts +719 -0
- package/src/index.ts +3299 -0
- package/src/sandbox/capabilities.ts +69 -0
- package/src/sandbox/channel-a.ts +1031 -0
- package/src/sandbox/display-stack.ts +231 -0
- package/src/sandbox/errors.ts +34 -0
- package/src/sandbox/index.ts +832 -0
- package/src/sandbox/providers/blaxel.ts +35 -0
- package/src/sandbox/providers/cloudflare.ts +24 -0
- package/src/sandbox/providers/daytona.ts +34 -0
- package/src/sandbox/providers/docker.ts +17 -0
- package/src/sandbox/providers/e2b.ts +36 -0
- package/src/sandbox/providers/index.ts +107 -0
- package/src/sandbox/providers/local.ts +13 -0
- package/src/sandbox/providers/modal.ts +55 -0
- package/src/sandbox/providers/none.ts +13 -0
- package/src/sandbox/providers/runloop.ts +32 -0
- package/src/sandbox/providers/selfhosted.ts +96 -0
- package/src/sandbox/providers/types.ts +38 -0
- package/src/sandbox/providers/vercel.ts +29 -0
- package/src/sandbox/recording.ts +286 -0
- package/src/sandbox/routing/backend-resolver.ts +189 -0
- package/src/sandbox/routing/routing-session.ts +455 -0
- package/src/sandbox/select.ts +371 -0
- package/src/sandbox/selfhosted/capabilities.ts +255 -0
- package/src/sandbox/selfhosted/control-rpc.ts +351 -0
- package/src/sandbox/selfhosted/session.ts +930 -0
- package/src/sandbox/selfhosted/testing.ts +230 -0
- package/src/sandbox/stream-port.ts +185 -0
- package/src/sandbox/stream-token.ts +90 -0
- package/src/sandbox/terminal-server.ts +203 -0
- package/src/sandbox-computer.ts +835 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// `RoutingSandboxSession` — the per-session hot-swap routing proxy (M7).
|
|
2
|
+
//
|
|
3
|
+
// THE load-bearing SDK finding (dossier §10.3 / §20-M7): when a box is injected
|
|
4
|
+
// NON-OWNED into a turn (`ownedSandbox.session`), the agent SDK's sandbox
|
|
5
|
+
// capabilities bind to that ONE session OBJECT ONCE and call ITS methods
|
|
6
|
+
// (`exec`/`execCommand`/`readFile`/`listDir`/`resolveExposedPort`/…) per tool
|
|
7
|
+
// call WITHOUT re-resolving the session. So to make the active sandbox flippable
|
|
8
|
+
// mid-turn we cannot swap the object the SDK holds — we must give the SDK ONE
|
|
9
|
+
// STABLE session-shaped object that, on EACH method call, re-reads the
|
|
10
|
+
// per-session active pointer `(active_sandbox_id, active_epoch)` and DISPATCHES
|
|
11
|
+
// to the CURRENTLY-active backend session (Modal or selfhosted).
|
|
12
|
+
//
|
|
13
|
+
// The contract (dossier §10.3):
|
|
14
|
+
// - ONE stable object implementing the `SandboxSessionLike` structural surface.
|
|
15
|
+
// - On EVERY op, re-read `(activeSandboxId, activeEpoch)` via `readPointer`.
|
|
16
|
+
// - Cache the resolved backend session keyed by `activeEpoch`; when the epoch
|
|
17
|
+
// changes mid-turn (a swap bumped it), re-resolve so the NEXT op hits the new
|
|
18
|
+
// backend. Single active at a time (NOT parallel multi-attach).
|
|
19
|
+
// - An in-flight op fenced by a STALE `active_epoch` (the backend rejects with a
|
|
20
|
+
// fence error, OR the pointer moved under us between read and dispatch)
|
|
21
|
+
// RETRIES against the new active sandbox — reusing the existing fenced-retry
|
|
22
|
+
// role. Bounded retries so a pathological swap-storm can't loop forever.
|
|
23
|
+
//
|
|
24
|
+
// This module is agent-loop-free (it lives in the sandbox leaf). It depends ONLY
|
|
25
|
+
// on injected closures (`readPointer` + `resolveActiveBackend`), so the API
|
|
26
|
+
// (`withChannelA`) and the worker (`resumeBoxForTurn`/the turn) wire it to the
|
|
27
|
+
// real `readActiveSandbox` DAO + a backend resolver without coupling the leaf to
|
|
28
|
+
// `@opengeni/db`.
|
|
29
|
+
|
|
30
|
+
import type { ExposedPortEndpoint } from "../stream-port";
|
|
31
|
+
|
|
32
|
+
/** The per-session active-sandbox pointer the proxy re-reads on every op. Mirror
|
|
33
|
+
* of `@opengeni/db`'s `ActiveSandboxPointer` (structural, so the leaf does not
|
|
34
|
+
* import the DB package). `activeSandboxId === null` == "use the session's own
|
|
35
|
+
* group sandbox" (the default/backward-compat target). */
|
|
36
|
+
export interface ActivePointer {
|
|
37
|
+
activeSandboxId: string | null;
|
|
38
|
+
activeEpoch: number;
|
|
39
|
+
/** The session's working directory — the path/cwd base for a selfhosted backend
|
|
40
|
+
* (threaded into the SelfhostedSession via the resolver). `null`/absent ⇒ the
|
|
41
|
+
* default workspace_root behavior. Optional so the default-pointer fallback
|
|
42
|
+
* (`{ activeSandboxId: null, activeEpoch: 0 }`) the readPointer wiring synthesizes
|
|
43
|
+
* when no row exists needs no extra field. Only the selfhosted branch reads it;
|
|
44
|
+
* the modal/default branches ignore it. */
|
|
45
|
+
workingDir?: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The structural slice of a backend session the routing proxy forwards to. It is
|
|
50
|
+
* a superset-by-optionality of every backend's surface (Modal's `SandboxSession`
|
|
51
|
+
* AND the `SelfhostedSession`): each method is optional because a heterogeneous
|
|
52
|
+
* target may or may not implement it, and the proxy reflects that at call-time.
|
|
53
|
+
*/
|
|
54
|
+
export interface RoutableBackendSession {
|
|
55
|
+
state?: unknown;
|
|
56
|
+
exec?(args: unknown): Promise<unknown>;
|
|
57
|
+
execCommand?(args: unknown): Promise<string>;
|
|
58
|
+
writeStdin?(args: unknown): Promise<string>;
|
|
59
|
+
readFile?(args: unknown): Promise<string | Uint8Array>;
|
|
60
|
+
writeFile?(args: unknown): Promise<unknown>;
|
|
61
|
+
createEditor?(runAs?: string): unknown;
|
|
62
|
+
listDir?(args: unknown): Promise<unknown>;
|
|
63
|
+
pathExists?(path: string, runAs?: string): Promise<boolean>;
|
|
64
|
+
viewImage?(args: unknown): Promise<unknown>;
|
|
65
|
+
materializeEntry?(args: unknown): Promise<void>;
|
|
66
|
+
supportsPty?(): boolean;
|
|
67
|
+
resolveExposedPort?(port: number): Promise<ExposedPortEndpoint>;
|
|
68
|
+
serializeSessionState?(): Promise<unknown>;
|
|
69
|
+
// The native-desktop control-plane surface (self-hosted / macOS): a backend that
|
|
70
|
+
// drives the desktop NATIVELY (input inject + frame capture) instead of shelling
|
|
71
|
+
// xdotool/scrot over `exec`. Optional like the rest — only a `SelfhostedSession`
|
|
72
|
+
// implements these; a Modal box does not. The computer-use capability duck-types
|
|
73
|
+
// on their PRESENCE (`isNativeDesktopSession`) to pick the native vs exec Computer.
|
|
74
|
+
// `event` is kept `unknown` (mirroring the interface's structural style + avoiding
|
|
75
|
+
// a proto import into the leaf); the SelfhostedSession takes `DesktopInputRequest["event"]`.
|
|
76
|
+
desktopInput?(event: unknown): Promise<void>;
|
|
77
|
+
screenshot?(): Promise<{ png: Uint8Array; width: number; height: number }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The resolved active backend for an epoch: the live session + the sandbox id it
|
|
81
|
+
* belongs to (`null` == the group sandbox) so a fence-retry can detect a move. */
|
|
82
|
+
export interface ResolvedActiveBackend {
|
|
83
|
+
session: RoutableBackendSession;
|
|
84
|
+
/** The sandbox id this backend serves (`null` == the session's group sandbox). */
|
|
85
|
+
sandboxId: string | null;
|
|
86
|
+
/** A label for diagnostics ("modal" | "selfhosted" | the sandbox name). */
|
|
87
|
+
kind: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RoutingSandboxSessionDeps {
|
|
91
|
+
/**
|
|
92
|
+
* The DEFAULT backend resolved at construction time (the same shape `resolve()`
|
|
93
|
+
* caches as `lastResolved`). This seeds `session.state` BEFORE the first op so a
|
|
94
|
+
* consumer that reads `session.state.manifest` at turn START — the @openai/agents
|
|
95
|
+
* SDK does, before any tool runs — sees the real default backend's state object
|
|
96
|
+
* (and writes to `session.state.manifest = …` land on it by reference), instead
|
|
97
|
+
* of an empty `{}` that crashes serializeManifestEnvironment /
|
|
98
|
+
* validateProvidedSessionManifestUpdate. The default-pointer case
|
|
99
|
+
* (`activeSandboxId === null`) resolves synchronously to this same backend, so
|
|
100
|
+
* seeding it here is byte-identical to what the first `resolve()` would produce.
|
|
101
|
+
*/
|
|
102
|
+
defaultResolved?: ResolvedActiveBackend;
|
|
103
|
+
/** Re-read the per-session active pointer. Called on EVERY op (the per-call
|
|
104
|
+
* re-resolve that makes a mid-turn swap visible to the next tool call). */
|
|
105
|
+
readPointer(): Promise<ActivePointer>;
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the active backend session for a pointer. The proxy memoizes the
|
|
108
|
+
* result by `activeEpoch`, so this is called at most once per epoch (per op the
|
|
109
|
+
* pointer is re-read, but the heavy resolve only re-runs when the epoch moved).
|
|
110
|
+
* For `pointer.activeSandboxId === null` this returns the default/group backend
|
|
111
|
+
* (typically the already-established turn box); for a non-null target it builds
|
|
112
|
+
* the target backend (a sibling Modal box or a selfhosted machine session).
|
|
113
|
+
*/
|
|
114
|
+
resolveActiveBackend(pointer: ActivePointer): Promise<ResolvedActiveBackend>;
|
|
115
|
+
/** Max fence/stale retries within a single op before surfacing the error.
|
|
116
|
+
* Defaults to 3 — enough to absorb a couple of concurrent swaps, bounded so a
|
|
117
|
+
* swap-storm cannot loop forever. */
|
|
118
|
+
maxFenceRetries?: number;
|
|
119
|
+
/** Optional structured-log sink for swap/fence transitions (diagnostics). */
|
|
120
|
+
onTransition?: (event: RoutingTransitionEvent) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RoutingTransitionEvent {
|
|
124
|
+
type: "resolved" | "fenced-retry" | "epoch-changed";
|
|
125
|
+
fromEpoch: number;
|
|
126
|
+
toEpoch: number;
|
|
127
|
+
sandboxId: string | null;
|
|
128
|
+
kind: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Thrown when the active backend does not implement the requested op (a
|
|
132
|
+
* heterogeneous target whose surface lacks the method the caller reached for). */
|
|
133
|
+
export class RoutingUnsupportedError extends Error {
|
|
134
|
+
readonly name = "RoutingUnsupportedError";
|
|
135
|
+
constructor(op: string, kind: string) {
|
|
136
|
+
super(`the active sandbox (${kind}) does not support "${op}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Recognize a stale-epoch FENCE error from a backend op so the proxy retries
|
|
141
|
+
* against the re-resolved active sandbox (the existing fenced-retry role). A
|
|
142
|
+
* selfhosted `SelfhostedControlError` carries `.fenced`; a generic fence is
|
|
143
|
+
* matched on the message as a fallback. */
|
|
144
|
+
function isFenceError(error: unknown): boolean {
|
|
145
|
+
if (!error || typeof error !== "object") {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if ((error as { fenced?: unknown }).fenced === true) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const name = typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : "";
|
|
152
|
+
const message = error instanceof Error ? error.message : String((error as { message?: unknown }).message ?? "");
|
|
153
|
+
const haystack = `${name} ${message}`.toLowerCase();
|
|
154
|
+
return haystack.includes("fenced") || haystack.includes("epoch") && haystack.includes("super");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ONE stable session-shaped object the SDK binds to. Every method re-reads the
|
|
159
|
+
* pointer, resolves the active backend (cached by epoch), and dispatches. A
|
|
160
|
+
* stale-epoch fence (the pointer moved mid-op) re-resolves and retries.
|
|
161
|
+
*
|
|
162
|
+
* The proxy implements ALL of the consumed surface so the SDK (which binds method
|
|
163
|
+
* presence ONCE) always sees `exec`/`readFile`/`resolveExposedPort`/… present. If
|
|
164
|
+
* the CURRENTLY-active backend lacks a method, the proxy applies the natural
|
|
165
|
+
* fallback (`exec`→`execCommand`) or throws `RoutingUnsupportedError` — degrade is
|
|
166
|
+
* a value, not a crash.
|
|
167
|
+
*
|
|
168
|
+
* `state` is a STABLE getter so a consumer reading `session.state` (channel-a's
|
|
169
|
+
* `readInstanceId`, the docker-network decoration) gets a coherent snapshot of the
|
|
170
|
+
* currently-active backend without a method call.
|
|
171
|
+
*/
|
|
172
|
+
export class RoutingSandboxSession implements RoutableBackendSession {
|
|
173
|
+
private readonly deps: RoutingSandboxSessionDeps;
|
|
174
|
+
private readonly maxFenceRetries: number;
|
|
175
|
+
// The per-epoch resolved-backend cache. Keyed by activeEpoch: a swap bumps the
|
|
176
|
+
// epoch, invalidating the cache so the NEXT op re-resolves the new backend.
|
|
177
|
+
private cachedEpoch: number | undefined;
|
|
178
|
+
private cached: ResolvedActiveBackend | undefined;
|
|
179
|
+
// The last-resolved backend, exposed via the `state` getter (a method-free read
|
|
180
|
+
// of the active backend's `state`). Updated on every resolve.
|
|
181
|
+
private lastResolved: ResolvedActiveBackend | undefined;
|
|
182
|
+
|
|
183
|
+
// The native-desktop control-plane ops (self-hosted / macOS). Declared as OPTIONAL
|
|
184
|
+
// INSTANCE fields — NOT prototype methods — because their PRESENCE is the selection
|
|
185
|
+
// signal `isNativeDesktopSession` (sandbox-computer.ts) uses to pick the native vs
|
|
186
|
+
// exec-shelling Computer. If they were unconditional prototype methods, this proxy
|
|
187
|
+
// would ALWAYS duck-type as native — misclassifying a Modal-fronting proxy (whose
|
|
188
|
+
// real backend has no native surface) and driving CGEvent/screenshot ops at a box
|
|
189
|
+
// that cannot serve them. So the constructor assigns them ONLY when the
|
|
190
|
+
// construction-time default backend actually implements the native surface (below).
|
|
191
|
+
desktopInput?: (event: unknown) => Promise<void>;
|
|
192
|
+
screenshot?: () => Promise<{ png: Uint8Array; width: number; height: number }>;
|
|
193
|
+
|
|
194
|
+
constructor(deps: RoutingSandboxSessionDeps) {
|
|
195
|
+
this.deps = deps;
|
|
196
|
+
this.maxFenceRetries = deps.maxFenceRetries ?? 3;
|
|
197
|
+
|
|
198
|
+
// Conditionally expose the native-desktop surface. Presence = the computer-use
|
|
199
|
+
// native/exec selection signal (isNativeDesktopSession duck-types on
|
|
200
|
+
// desktopInput+screenshot being functions); unconditional presence would
|
|
201
|
+
// misclassify Modal-fronting proxies as native. So we mint these per-INSTANCE
|
|
202
|
+
// arrow properties ONLY when the default backend resolved at construction is
|
|
203
|
+
// itself native-capable — the machine-primary (selfhosted) case. Each dispatches
|
|
204
|
+
// to the ACTIVE backend at call-time; if a mid-turn swap lands on a backend that
|
|
205
|
+
// lacks the op (a cross-kind swap to a Modal box), dispatch throws
|
|
206
|
+
// RoutingUnsupportedError — a legible tool failure, never a silent Linux-tool
|
|
207
|
+
// shell onto a Mac.
|
|
208
|
+
const def = deps.defaultResolved?.session;
|
|
209
|
+
if (typeof def?.desktopInput === "function" && typeof def?.screenshot === "function") {
|
|
210
|
+
this.desktopInput = (event: unknown) =>
|
|
211
|
+
this.dispatch("desktopInput", async (s) => {
|
|
212
|
+
if (!s.desktopInput) {
|
|
213
|
+
throw new RoutingUnsupportedError("desktopInput", this.cached?.kind ?? "unknown");
|
|
214
|
+
}
|
|
215
|
+
return s.desktopInput(event);
|
|
216
|
+
});
|
|
217
|
+
this.screenshot = () =>
|
|
218
|
+
this.dispatch("screenshot", async (s) => {
|
|
219
|
+
if (!s.screenshot) {
|
|
220
|
+
throw new RoutingUnsupportedError("screenshot", this.cached?.kind ?? "unknown");
|
|
221
|
+
}
|
|
222
|
+
return s.screenshot();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A method-free read of the active backend's `state` (best-effort: the last
|
|
229
|
+
* resolved backend, falling back to the default backend resolved at construction
|
|
230
|
+
* so this is non-empty BEFORE the first op). Consumers that read `session.state`
|
|
231
|
+
* (instanceId/decoration) get the active backend's state.
|
|
232
|
+
*
|
|
233
|
+
* CRITICAL: this returns the underlying backend's `state` OBJECT BY REFERENCE
|
|
234
|
+
* (never a fresh `{}` when a backend exists). The @openai/agents SDK both READS
|
|
235
|
+
* `session.state.manifest` and WRITES `session.state.manifest = nextManifest`
|
|
236
|
+
* (providedSessionManifest); returning the live object by reference means those
|
|
237
|
+
* property writes land on the real backend state and persist. Only when NO
|
|
238
|
+
* backend has been resolved yet (no default seeded, no op dispatched) do we
|
|
239
|
+
* return an empty object — and that path no longer occurs in the turn wiring,
|
|
240
|
+
* which always seeds `defaultResolved`.
|
|
241
|
+
*/
|
|
242
|
+
get state(): unknown {
|
|
243
|
+
const backendState = (this.lastResolved ?? this.deps.defaultResolved)?.session.state;
|
|
244
|
+
return backendState ?? {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Re-read the pointer and resolve the active backend, using the per-epoch cache.
|
|
249
|
+
* The cache is keyed by `activeEpoch`: if the epoch is unchanged we return the
|
|
250
|
+
* cached backend; if it moved (a swap) we re-resolve and update the cache. This
|
|
251
|
+
* is THE per-call re-read that makes a mid-turn swap land on the next op.
|
|
252
|
+
*/
|
|
253
|
+
private async resolve(): Promise<ResolvedActiveBackend> {
|
|
254
|
+
const pointer = await this.deps.readPointer();
|
|
255
|
+
if (this.cachedEpoch === pointer.activeEpoch && this.cached) {
|
|
256
|
+
return this.cached;
|
|
257
|
+
}
|
|
258
|
+
const fromEpoch = this.cachedEpoch ?? pointer.activeEpoch;
|
|
259
|
+
const resolved = await this.deps.resolveActiveBackend(pointer);
|
|
260
|
+
this.cachedEpoch = pointer.activeEpoch;
|
|
261
|
+
this.cached = resolved;
|
|
262
|
+
this.lastResolved = resolved;
|
|
263
|
+
this.deps.onTransition?.({
|
|
264
|
+
type: this.cachedEpoch !== undefined && fromEpoch !== pointer.activeEpoch ? "epoch-changed" : "resolved",
|
|
265
|
+
fromEpoch,
|
|
266
|
+
toEpoch: pointer.activeEpoch,
|
|
267
|
+
sandboxId: resolved.sandboxId,
|
|
268
|
+
kind: resolved.kind,
|
|
269
|
+
});
|
|
270
|
+
return resolved;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Dispatch an op to the currently-active backend, retrying on a stale-epoch
|
|
275
|
+
* fence. The sequence per attempt:
|
|
276
|
+
* 1. re-read the pointer + resolve the active backend (cached by epoch),
|
|
277
|
+
* 2. run `fn(activeSession)`,
|
|
278
|
+
* 3. on a FENCE error (the pointer moved under us / the backend rejected a
|
|
279
|
+
* stale epoch), INVALIDATE the cache and retry against the re-resolved
|
|
280
|
+
* active sandbox — up to `maxFenceRetries`.
|
|
281
|
+
* A non-fence error propagates immediately (it is a real op failure, not a swap
|
|
282
|
+
* race).
|
|
283
|
+
*/
|
|
284
|
+
private async dispatch<T>(op: string, fn: (session: RoutableBackendSession) => Promise<T>): Promise<T> {
|
|
285
|
+
let attempt = 0;
|
|
286
|
+
let lastError: unknown;
|
|
287
|
+
while (attempt <= this.maxFenceRetries) {
|
|
288
|
+
const backend = await this.resolve();
|
|
289
|
+
try {
|
|
290
|
+
return await fn(backend.session);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!isFenceError(error)) {
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
// Stale-epoch fence: the active pointer moved mid-op. Drop the cache so
|
|
296
|
+
// the next resolve re-reads the NEW pointer and the op lands on the new
|
|
297
|
+
// active sandbox (the fenced-retry role). Bounded by maxFenceRetries.
|
|
298
|
+
lastError = error;
|
|
299
|
+
this.cachedEpoch = undefined;
|
|
300
|
+
this.cached = undefined;
|
|
301
|
+
this.deps.onTransition?.({
|
|
302
|
+
type: "fenced-retry",
|
|
303
|
+
fromEpoch: backend.sandboxId === null ? 0 : 0,
|
|
304
|
+
toEpoch: 0,
|
|
305
|
+
sandboxId: backend.sandboxId,
|
|
306
|
+
kind: backend.kind,
|
|
307
|
+
});
|
|
308
|
+
attempt += 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Exhausted retries against a relentless swap-storm: surface the fence so the
|
|
312
|
+
// caller (turn) backs off — never loop forever.
|
|
313
|
+
throw lastError ?? new Error(`routing op "${op}" exhausted fence retries`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── The forwarded structural surface ──────────────────────────────────────
|
|
317
|
+
// Every method is PRESENT on the proxy (the SDK binds presence once) and
|
|
318
|
+
// dispatches to the active backend at call-time. A missing backend method
|
|
319
|
+
// degrades via the natural fallback or RoutingUnsupportedError.
|
|
320
|
+
|
|
321
|
+
async exec(args: unknown): Promise<unknown> {
|
|
322
|
+
return this.dispatch("exec", async (s) => {
|
|
323
|
+
if (s.exec) {
|
|
324
|
+
return s.exec(args);
|
|
325
|
+
}
|
|
326
|
+
// Some backends (selfhosted) only expose exec; others only execCommand.
|
|
327
|
+
if (s.execCommand) {
|
|
328
|
+
return s.execCommand(args);
|
|
329
|
+
}
|
|
330
|
+
throw new RoutingUnsupportedError("exec", this.cached?.kind ?? "unknown");
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async execCommand(args: unknown): Promise<string> {
|
|
335
|
+
return this.dispatch("execCommand", async (s) => {
|
|
336
|
+
if (s.execCommand) {
|
|
337
|
+
return s.execCommand(args);
|
|
338
|
+
}
|
|
339
|
+
if (s.exec) {
|
|
340
|
+
const r = (await s.exec(args)) as { stdout?: string; output?: string };
|
|
341
|
+
return r.stdout ?? r.output ?? "";
|
|
342
|
+
}
|
|
343
|
+
throw new RoutingUnsupportedError("execCommand", this.cached?.kind ?? "unknown");
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async writeStdin(args: unknown): Promise<string> {
|
|
348
|
+
return this.dispatch("writeStdin", async (s) => {
|
|
349
|
+
if (!s.writeStdin) {
|
|
350
|
+
throw new RoutingUnsupportedError("writeStdin", this.cached?.kind ?? "unknown");
|
|
351
|
+
}
|
|
352
|
+
return s.writeStdin(args);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async readFile(args: unknown): Promise<string | Uint8Array> {
|
|
357
|
+
return this.dispatch("readFile", async (s) => {
|
|
358
|
+
if (!s.readFile) {
|
|
359
|
+
throw new RoutingUnsupportedError("readFile", this.cached?.kind ?? "unknown");
|
|
360
|
+
}
|
|
361
|
+
return s.readFile(args);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async writeFile(args: unknown): Promise<unknown> {
|
|
366
|
+
return this.dispatch("writeFile", async (s) => {
|
|
367
|
+
if (!s.writeFile) {
|
|
368
|
+
throw new RoutingUnsupportedError("writeFile", this.cached?.kind ?? "unknown");
|
|
369
|
+
}
|
|
370
|
+
return s.writeFile(args);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async listDir(args: unknown): Promise<unknown> {
|
|
375
|
+
return this.dispatch("listDir", async (s) => {
|
|
376
|
+
if (!s.listDir) {
|
|
377
|
+
throw new RoutingUnsupportedError("listDir", this.cached?.kind ?? "unknown");
|
|
378
|
+
}
|
|
379
|
+
return s.listDir(args);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async pathExists(path: string, runAs?: string): Promise<boolean> {
|
|
384
|
+
return this.dispatch("pathExists", async (s) => {
|
|
385
|
+
if (!s.pathExists) {
|
|
386
|
+
throw new RoutingUnsupportedError("pathExists", this.cached?.kind ?? "unknown");
|
|
387
|
+
}
|
|
388
|
+
return s.pathExists(path, runAs);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async viewImage(args: unknown): Promise<unknown> {
|
|
393
|
+
return this.dispatch("viewImage", async (s) => {
|
|
394
|
+
if (!s.viewImage) {
|
|
395
|
+
throw new RoutingUnsupportedError("viewImage", this.cached?.kind ?? "unknown");
|
|
396
|
+
}
|
|
397
|
+
return s.viewImage(args);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async materializeEntry(args: unknown): Promise<void> {
|
|
402
|
+
return this.dispatch("materializeEntry", async (s) => {
|
|
403
|
+
if (!s.materializeEntry) {
|
|
404
|
+
throw new RoutingUnsupportedError("materializeEntry", this.cached?.kind ?? "unknown");
|
|
405
|
+
}
|
|
406
|
+
return s.materializeEntry(args);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** PTY support reflects the LAST-resolved backend (a synchronous probe; the SDK
|
|
411
|
+
* reads it to decide if the terminal is interactive). It cannot re-read the
|
|
412
|
+
* pointer (synchronous), so it answers from the last resolve — coherent with
|
|
413
|
+
* the resolve the surrounding op already performed. Defaults false before the
|
|
414
|
+
* first resolve. */
|
|
415
|
+
supportsPty(): boolean {
|
|
416
|
+
const s = (this.lastResolved ?? this.deps.defaultResolved)?.session;
|
|
417
|
+
return Boolean(s?.supportsPty?.());
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** createEditor is a synchronous factory in the SDK surface; it binds to the
|
|
421
|
+
* last-resolved backend's editor (or the default backend before the first op).
|
|
422
|
+
* Returns undefined when the active backend has no editor (channel-a falls back
|
|
423
|
+
* to its exec-based write path). */
|
|
424
|
+
createEditor(runAs?: string): unknown {
|
|
425
|
+
return (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async resolveExposedPort(port: number): Promise<ExposedPortEndpoint> {
|
|
429
|
+
return this.dispatch("resolveExposedPort", async (s) => {
|
|
430
|
+
if (!s.resolveExposedPort) {
|
|
431
|
+
throw new RoutingUnsupportedError("resolveExposedPort", this.cached?.kind ?? "unknown");
|
|
432
|
+
}
|
|
433
|
+
return s.resolveExposedPort(port);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Serialize the active backend's session state. Used by the resume-by-id seam
|
|
438
|
+
* to fold the live box onto the lease. Dispatches to the active backend. */
|
|
439
|
+
async serializeSessionState(): Promise<unknown> {
|
|
440
|
+
return this.dispatch("serializeSessionState", async (s) => {
|
|
441
|
+
if (!s.serializeSessionState) {
|
|
442
|
+
// No-op for a backend with no serializable state (selfhosted state is
|
|
443
|
+
// re-addressed, not snapshotted) — surface undefined, not an error.
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return s.serializeSessionState();
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Force a resolve (priming the proxy before the first op so `state`/`supportsPty`
|
|
451
|
+
* read a real backend). Optional — every op resolves lazily anyway. */
|
|
452
|
+
async prime(): Promise<ResolvedActiveBackend> {
|
|
453
|
+
return this.resolve();
|
|
454
|
+
}
|
|
455
|
+
}
|