hyperbrane 0.1.1 → 0.1.2

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 CHANGED
@@ -24,11 +24,39 @@ hyperbrane dimension create --dir ~/work -m 4G --cpus 2 work
24
24
  import { createServices, createSshSandbox, MemoryLimit, CPULimit } from "hyperbrane";
25
25
 
26
26
  const host = createSshSandbox("server.example.com", { user: "me", privateKey });
27
- const { dimensions, agents } = createServices(host);
27
+ const { gateways, dimensions, portals, agents } = createServices(host);
28
+ // The gateway keeps the sign-ins, under its name: a gateway by another name starts with none.
29
+ await gateways.createGateway({ name: "main" });
28
30
  await dimensions.createDimension({ name: "work", directory: "work", memoryLimit: MemoryLimit("4G"), cpuLimit: CPULimit("2") });
31
+ // A dimension's only way out: before installing anything in it.
32
+ await portals.createPortal({ name: "work-main", dimensionName: "work", gatewayName: "main" });
29
33
  await agents.createAgent({ name: "dev", dimensionName: "work" });
34
+ await agents.install("dev", "claude");
30
35
  ```
31
36
 
37
+ Each call resolves once what it made is usable: a gateway once it intercepts, a portal once traffic
38
+ flows through it, an install once the backend runs. Re-running any of them converges.
39
+
40
+ hyperbrane runs where the application does and drives the machine from there: over SSH, or for Boat
41
+ through Boat's API, so the application needs nothing from the machine's own network or DNS. A
42
+ browser reaches an agent through `agents.exposeAgent`'s endpoint, and give it the page's origin:
43
+ origins add up, so a call without one removes nothing. The endpoint's URL lasts, but its proxy stops
44
+ with a reboot or Boat archive, and a page connecting then sees a CORS error. `exposeAgent` starts it
45
+ again, at the same URL: give `workerSseConnector` a function that calls it (through the page's server),
46
+ and it does so again whenever a connection fails to open, then throws `EndpointUnreachableError` if
47
+ that didn't help.
48
+
49
+ What lasts:
50
+
51
+ | | Container restart | Container recreated | Machine reboot, Boat resume |
52
+ |---|---|---|---|
53
+ | Workspace, sessions, sign-ins, installed backends | kept | kept | kept |
54
+ | Agent workers | started again by the next call (or `agents.restoreAgents()`) | same | same |
55
+ | Portals | re-wired by `restartDimension` and `restartGateway`; otherwise as on the right | re-wired by the next agent call, `portals.restorePortals()`, or `createPortalWatcher` (running) | same |
56
+
57
+ A Boat sandbox stops itself after a while; `hasExpiration(sandbox)` tells a sandbox that does, and
58
+ `setExpiration` moves it (`createBoatSandbox`'s own type has both).
59
+
32
60
  The root module exports the services, the sandboxes (`createLocalSandbox`, `createSshSandbox`,
33
61
  `createContainerSandbox`, `createBoatSandbox`), the endpoint auths and the agent client. For a
34
62
  browser, which can't take the server side along, the package also exports each part's API
@@ -3,13 +3,18 @@ import type { Sandbox, SandboxAccessibleEndpoint } from "../sandbox/api.ts";
3
3
  import type { AgentStorage } from "./agentStorage.ts";
4
4
  import type { GatewaySignIns } from "./gatewaySignIns.ts";
5
5
  import { type AgentClient, type AgentEndpoint, type AgentProvision, type AgentSessionCapabilities } from "./api.ts";
6
+ import type { PortalService } from "../portal-gateway/portalService.ts";
6
7
  /** @sealed */
7
8
  export interface AgentService extends AgentClient, AgentProvision, AgentSessionCapabilities, AgentEndpoint {
8
9
  /** Starts the workers that a restart took down (their dimension runs again); returns their agents' names. */
9
10
  restoreAgents(): Promise<string[]>;
10
11
  }
11
- /** In the dimension: where the backends are installed (a package with its node_modules). Shared by agents. */
12
- export declare const WORKER_DIR = "/opt/hyperbrane/worker";
12
+ /**
13
+ * In the dimension: where the backends are installed (a package with its node_modules). Shared by
14
+ * agents. In the state directory, so they last as long as the dimension: a container recreated (or a
15
+ * machine whose docker kept nothing) doesn't take them, and nothing is installed twice.
16
+ */
17
+ export declare const WORKER_DIR = "/var/lib/hyperbrane/worker";
13
18
  /**
14
19
  * In the dimension: the worker's script. Outside WORKER_DIR on purpose: bun caches the listing of the
15
20
  * directory a script is loaded from, and packages installed there afterwards didn't resolve.
@@ -29,4 +34,6 @@ export declare const WORKER_SCRIPT = "/opt/hyperbrane/worker.js";
29
34
  * `host` is the machine docker runs on: workers' sockets and logs are reached through it, and agents
30
35
  * are exposed through its endpoints when it declares them.
31
36
  */
32
- export declare function createAgentService(storage: AgentStorage, docker: DockerClient, host: Sandbox | (Sandbox & SandboxAccessibleEndpoint), gatewaySignIns?: GatewaySignIns): AgentService;
37
+ export declare function createAgentService(storage: AgentStorage, docker: DockerClient, host: Sandbox | (Sandbox & SandboxAccessibleEndpoint), gatewaySignIns?: GatewaySignIns,
38
+ /** Re-wires a dimension's portals: what a restart (its container's, the machine's) took. */
39
+ restoring?: Pick<PortalService, "restorePortals">): AgentService;
@@ -11,8 +11,14 @@ import { readHostTranscript } from "./hostTranscript.js";
11
11
  import { workerSocketPath } from "./session/sessionStorage.js";
12
12
  import { WORKER_PARAMS } from "./session/worker/workerParams.js";
13
13
  import { workerBundle } from "./workerBundle.js";
14
- /** In the dimension: where the backends are installed (a package with its node_modules). Shared by agents. */
15
- export const WORKER_DIR = "/opt/hyperbrane/worker";
14
+ /**
15
+ * In the dimension: where the backends are installed (a package with its node_modules). Shared by
16
+ * agents. In the state directory, so they last as long as the dimension: a container recreated (or a
17
+ * machine whose docker kept nothing) doesn't take them, and nothing is installed twice.
18
+ */
19
+ export const WORKER_DIR = `${STATE_PATH}/worker`;
20
+ /** Where they were before: moved to WORKER_DIR when a worker starts (ensureWorker). */
21
+ const OLD_WORKER_DIR = "/opt/hyperbrane/worker";
16
22
  /**
17
23
  * In the dimension: the worker's script. Outside WORKER_DIR on purpose: bun caches the listing of the
18
24
  * directory a script is loaded from, and packages installed there afterwards didn't resolve.
@@ -53,7 +59,9 @@ done
53
59
  * `host` is the machine docker runs on: workers' sockets and logs are reached through it, and agents
54
60
  * are exposed through its endpoints when it declares them.
55
61
  */
56
- export function createAgentService(storage, docker, host, gatewaySignIns) {
62
+ export function createAgentService(storage, docker, host, gatewaySignIns,
63
+ /** Re-wires a dimension's portals: what a restart (its container's, the machine's) took. */
64
+ restoring) {
57
65
  const endpoints = "exposeEndpoint" in host ? host : null;
58
66
  async function runningDimension(dimensionName) {
59
67
  const container = dimensionContainerName(dimensionName);
@@ -68,7 +76,7 @@ export function createAgentService(storage, docker, host, gatewaySignIns) {
68
76
  if (!runDirectory || !hasState) {
69
77
  throw new AgentConflictError(`dimension ${dimensionName} lacks the ${RUN_PATH} and ${STATE_PATH} mounts agents need (it predates them); recreate it`);
70
78
  }
71
- return { container, owner: inspected.Config.User || "0:0", runDirectory };
79
+ return { name: dimensionName, container, owner: inspected.Config.User || "0:0", runDirectory };
72
80
  }
73
81
  /** The worker as it is: for starting, stopping and checking on it. */
74
82
  function rawWorkerClient(dimension, agentName) {
@@ -97,11 +105,16 @@ export function createAgentService(storage, docker, host, gatewaySignIns) {
97
105
  function reviveWorker(agentName, dimension) {
98
106
  let started = reviving.get(agentName);
99
107
  if (!started) {
100
- started = ensureWorker(agentName, dimension)
108
+ // Whatever took the worker down (a restart, a reboot, a Boat resume) took the dimension's
109
+ // portals too: without them the worker, and the backends it installs, can't reach anything.
110
+ const restorePortals = () => restoring?.restorePortals({ dimensionName: dimension.name });
111
+ started = Promise.resolve(restorePortals())
112
+ .then(() => ensureWorker(agentName, dimension))
101
113
  .catch(async () => {
102
114
  // Unreachable even freshly started: the container's mounts may no longer be the host's
103
115
  // directories (it started while they were something else). A restart binds them again.
104
116
  await docker.restartContainer(dimension.container);
117
+ await restorePortals();
105
118
  await ensureWorker(agentName, dimension);
106
119
  })
107
120
  .finally(() => reviving.delete(agentName));
@@ -163,9 +176,13 @@ export function createAgentService(storage, docker, host, gatewaySignIns) {
163
176
  return;
164
177
  const root = createContainerSandbox(docker, container, { user: "root" });
165
178
  // The run and state directories too: the host created them, possibly as another user than the dimension's.
166
- const setup = await root.runCommand(["sh", "-c", 'mkdir -p "$W" && chown "$OWNER" "$(dirname "$W")" "$W" "$R" "$S"'], {
167
- env: { W: WORKER_DIR, R: RUN_PATH, S: STATE_PATH, OWNER: owner },
168
- });
179
+ // Backends installed where they used to be move along, once.
180
+ const setup = await root.runCommand([
181
+ "sh",
182
+ "-c",
183
+ 'mkdir -p "$W" "$(dirname "$SCRIPT")" && chown "$OWNER" "$(dirname "$SCRIPT")" "$W" "$R" "$S" && ' +
184
+ '{ [ -e "$W/node_modules" ] || [ ! -d "$OLD/node_modules" ] || { cp -a "$OLD/." "$W/" && rm -rf "$OLD"; }; }',
185
+ ], { env: { W: WORKER_DIR, SCRIPT: WORKER_SCRIPT, OLD: OLD_WORKER_DIR, R: RUN_PATH, S: STATE_PATH, OWNER: owner } });
169
186
  if (setup.exitCode !== 0)
170
187
  throw new Error(`preparing ${container} for agent ${agentName} failed: ${setup.stderr.trim()}`);
171
188
  const sandbox = createContainerSandbox(docker, container);
@@ -278,23 +295,34 @@ export function createAgentService(storage, docker, host, gatewaySignIns) {
278
295
  // Lazy: the stored endpoint is served again if its proxy stopped (a reboot), with the same URL and
279
296
  // credentials. The proxy connects to the worker's socket per stream, so a worker restart is fine.
280
297
  // Each way the host is reached (its accessible host) has an endpoint of its own: several server
281
- // records may drive the same machine.
298
+ // records may drive the same machine. Origins only add up: the endpoint is shared, and one caller's
299
+ // origins must not cut off another's pages (a narrower set closes nothing). What can run at once does.
282
300
  async function exposeAgent(agentName, scheme, allowedOrigins = []) {
283
- const agent = await requireAgent(agentName);
284
301
  if (!endpoints)
285
302
  throw new AgentInvalidError("this host doesn't declare accessible endpoints");
303
+ const key = endpoints.accessibleHost;
304
+ const [agent, stored] = await Promise.all([requireAgent(agentName), storage.readEndpoints(agentName).then((all) => all[key])]);
286
305
  const dimension = await runningDimension(agent.dimensionName);
306
+ const [healthy, served] = await Promise.all([
307
+ rawWorkerClient(dimension, agentName).call("worker/health", {}).then(() => true, () => false),
308
+ stored && stored.credentials.scheme === scheme ? endpoints.ensureEndpoint(stored.id) : false,
309
+ ]);
287
310
  // The client connects to the worker's socket through the endpoint: nothing on its way could start
288
311
  // a worker a restart took down, so it's started here.
289
- if (!(await rawWorkerClient(dimension, agentName).call("worker/health", {}).then(() => true, () => false))) {
312
+ if (!healthy)
290
313
  await reviveWorker(agentName, dimension);
314
+ const origins = [...new Set([...(stored?.allowedOrigins ?? []), ...allowedOrigins])];
315
+ if (stored && served) {
316
+ if (origins.every((origin) => stored.allowedOrigins.includes(origin)))
317
+ return stored;
318
+ // Same URL and credentials, so pages already connected stay connected.
319
+ if (await endpoints.updateEndpoint(stored.id, { allowedOrigins: origins })) {
320
+ const updated = { ...stored, allowedOrigins: origins };
321
+ await storage.saveEndpoint(agentName, key, updated);
322
+ return updated;
323
+ }
291
324
  }
292
- const key = endpoints.accessibleHost;
293
- const stored = (await storage.readEndpoints(agentName))[key];
294
- const sameOrigins = (origins) => [...(origins ?? [])].sort().join("\n") === [...allowedOrigins].sort().join("\n");
295
- if (stored && stored.credentials.scheme === scheme && sameOrigins(stored.allowedOrigins) && (await endpoints.ensureEndpoint(stored.id))) {
296
- return stored;
297
- }
325
+ // Another scheme, or it can't be served as it was given out (closed, its port taken).
298
326
  if (stored) {
299
327
  await endpoints.closeEndpoint(stored.id);
300
328
  await storage.removeEndpoint(agentName, key);
@@ -303,7 +331,7 @@ export function createAgentService(storage, docker, host, gatewaySignIns) {
303
331
  target: { unix: workerSocketPath(dimension.runDirectory, agentName) },
304
332
  scheme,
305
333
  transport: "sse",
306
- allowedOrigins,
334
+ allowedOrigins: origins,
307
335
  });
308
336
  await storage.saveEndpoint(agentName, key, endpoint);
309
337
  return endpoint;
@@ -371,6 +399,7 @@ export function createAgentService(storage, docker, host, gatewaySignIns) {
371
399
  exposeAgent,
372
400
  restoreAgents,
373
401
  install: async (agentName, backend) => void (await (await clientFor(agentName)).call("backend/install", { backend })),
402
+ listBackends: async (agentName) => (await clientFor(agentName)).call("backend/list", {}),
374
403
  createSession: async (agentName, session) => (await clientFor(agentName)).call("session/create", { session }),
375
404
  updateSession: async (ref, limits) => (await sessionClient(ref)).call("session/update", { session: ref.sessionName, limits }),
376
405
  stopSession: async (ref) => void (await (await sessionClient(ref)).call("session/stop", { session: ref.sessionName })),
@@ -159,20 +159,40 @@ export type AgentAuthStatus = {
159
159
  readonly userCode?: string;
160
160
  readonly needsCode: boolean;
161
161
  };
162
+ /**
163
+ * A backend in an agent's dimension: the version installed (null: none), and whether it's ready to
164
+ * run, i.e. this release's version, complete. Kept in the dimension's state directory, so it lasts as
165
+ * long as the dimension, its container recreated or not.
166
+ */
167
+ export type AgentBackendInfo = {
168
+ readonly backend: AgentBackend;
169
+ readonly version: string | null;
170
+ readonly ready: boolean;
171
+ };
162
172
  /** @sealed */
163
173
  export interface AgentProvision {
164
- /** POST /v1/agents/:agentName/backends/:backend/install — installs the backend in the dimension, or updates it. */
174
+ /**
175
+ * POST /v1/agents/:agentName/backends/:backend/install — installs the backend in the dimension, or
176
+ * updates it, or completes it; nothing to do when it's ready. Downloads through the dimension's portals.
177
+ */
165
178
  install(agentName: string, backend: AgentBackend): Promise<void>;
179
+ /** GET /v1/agents/:agentName/backends — each backend, as installed in the agent's dimension. */
180
+ listBackends(agentName: string): Promise<AgentBackendInfo[]>;
166
181
  }
167
182
  /** @sealed */
168
183
  export interface AgentEndpoint {
169
184
  /**
170
185
  * POST /v1/agents/:agentName/endpoint — the agent's worker, reachable from the client over the
171
186
  * network (workerSseConnector speaks to it), authenticated by `scheme`, callable from pages of
172
- * `allowedOrigins`. Call it before each connection: it starts the endpoint's proxy again if that
173
- * stopped, keeping the same URL and credentials; a new endpoint only for another scheme or origins,
174
- * or once the old one can't be served (closed, its port taken). Closed with the agent. Needs a host
175
- * that declares endpoints.
187
+ * `allowedOrigins`. Its URL lasts, but the proxy behind it doesn't: an archived and resumed Boat
188
+ * sandbox, or a reboot, stops it, and a page connecting then gets what a browser reports as a CORS
189
+ * error. This call (three round trips to the host when nothing changed) starts the agent's worker
190
+ * and the endpoint's proxy again if they stopped, keeping the same URL and credentials: give
191
+ * workerSseConnector a function that calls it, and it calls it again whenever a connection fails
192
+ * to open; an endpoint kept elsewhere goes stale. Origins add up: those given join the endpoint's own, in place, so
193
+ * pages already connected stay connected, and a call with fewer (or none) removes nothing. A new
194
+ * endpoint only for another scheme, or once the old one can't be served (its port taken); the old
195
+ * one's connections end then. Closed with the agent. Needs a host that declares endpoints.
176
196
  */
177
197
  exposeAgent(agentName: string, scheme: SandboxEndpointScheme, allowedOrigins?: string[]): Promise<SandboxEndpoint>;
178
198
  }
@@ -285,4 +305,11 @@ export declare class AgentNotFoundError extends Error {
285
305
  export declare class AgentConflictError extends Error {
286
306
  name: string;
287
307
  }
308
+ /**
309
+ * The session's backend isn't installed in its dimension, or not completely (AgentBackendInfo.ready):
310
+ * AgentProvision.install puts it there. A conflict with the dimension's state, so caught as one too.
311
+ */
312
+ export declare class AgentBackendNotInstalledError extends AgentConflictError {
313
+ name: string;
314
+ }
288
315
  export {};
package/dist/agent/api.js CHANGED
@@ -12,3 +12,10 @@ export class AgentNotFoundError extends Error {
12
12
  export class AgentConflictError extends Error {
13
13
  name = "AgentConflictError";
14
14
  }
15
+ /**
16
+ * The session's backend isn't installed in its dimension, or not completely (AgentBackendInfo.ready):
17
+ * AgentProvision.install puts it there. A conflict with the dimension's state, so caught as one too.
18
+ */
19
+ export class AgentBackendNotInstalledError extends AgentConflictError {
20
+ name = "AgentBackendNotInstalledError";
21
+ }
@@ -1,3 +1,3 @@
1
1
  export { createWorkerClient, type WorkerClient } from "./session/client.ts";
2
2
  export { sharedConnector, type SharedConnector } from "./session/sharedConnection.ts";
3
- export { EndpointLoginRequiredError, exeDevLoginUrl, workerSseConnector, type WorkerEndpoint } from "./session/workerSseConnection.ts";
3
+ export { EndpointLoginRequiredError, EndpointUnreachableError, exeDevLoginUrl, workerSseConnector, type WorkerEndpoint } from "./session/workerSseConnection.ts";
@@ -2,4 +2,4 @@
2
2
  // import (hyperbrane/agent/client), since the root module brings the server side with it.
3
3
  export { createWorkerClient } from "./session/client.js";
4
4
  export { sharedConnector } from "./session/sharedConnection.js";
5
- export { EndpointLoginRequiredError, exeDevLoginUrl, workerSseConnector } from "./session/workerSseConnection.js";
5
+ export { EndpointLoginRequiredError, EndpointUnreachableError, exeDevLoginUrl, workerSseConnector } from "./session/workerSseConnection.js";
@@ -1,4 +1,4 @@
1
- import type { AgentBackgroundTasks, AgentInput, AgentInterrupt, AgentMessageQueue, AgentModels, AgentPermissionRequest, AgentPermissionResult, AgentQuestionRequest, AgentQuestionResponse, AgentSession, AgentSessionStatus, AgentStreamingMessage, SessionRef } from "../api.ts";
1
+ import type { AgentBackendInfo, AgentBackgroundTasks, AgentInput, AgentInterrupt, AgentMessageQueue, AgentModels, AgentPermissionRequest, AgentPermissionResult, AgentQuestionRequest, AgentQuestionResponse, AgentSession, AgentSessionStatus, AgentStreamingMessage, SessionRef } from "../api.ts";
2
2
  import type { FileTranscript, TranscriptFiles } from "./fileTranscript.ts";
3
3
  /** A capability's calls for one session: those of api.ts, with the session already chosen. */
4
4
  export type ForSession<Capability> = {
@@ -71,6 +71,8 @@ export type TranscriptSource = {
71
71
  export type SessionBackend = {
72
72
  /** Installs the backend under `installDir`, or updates it to the pinned version. Runs in the dimension. */
73
73
  install(installDir: string): Promise<void>;
74
+ /** What's installed under `installDir`, and whether it's what install leaves: this release's version, complete. */
75
+ installed(installDir: string): Promise<Pick<AgentBackendInfo, "version" | "ready">>;
74
76
  start(context: SessionContext): Promise<RunningSession>;
75
77
  auth: BackendAuth;
76
78
  /** The conversation `nativeId` in the backend's own storage, which needs no CLI running. */
@@ -1,10 +1,12 @@
1
- import { type AgentAuthStatus, type AgentBackend, type AgentBackgroundTask, type AgentLastUserMessage, type AgentModelInfo, type AgentPermissionRequest, type AgentPermissionResult, type AgentQuestionRequest, type AgentQuestionResponse, type AgentSession, type AgentSessionInfo, type AgentSessionLimits, type AgentSessionState, type AgentStreamingMessage, type AgentTranscriptPage, type UserMessage } from "../../api.ts";
1
+ import { type AgentAuthStatus, type AgentBackend, type AgentBackendInfo, type AgentBackgroundTask, type AgentLastUserMessage, type AgentModelInfo, type AgentPermissionRequest, type AgentPermissionResult, type AgentQuestionRequest, type AgentQuestionResponse, type AgentSession, type AgentSessionInfo, type AgentSessionLimits, type AgentSessionState, type AgentStreamingMessage, type AgentTranscriptPage, type UserMessage } from "../../api.ts";
2
2
  import type { JsonRpcErrorObject } from "./jsonRpc.ts";
3
3
  /**
4
4
  * The worker's JSON-RPC protocol, spoken over its unix socket (see createWorkerServer): method ->
5
5
  * [params, result]. Sessions are named by `session`; everything answers `null` unless it says.
6
6
  * worker/health and worker/shutdown are how the host tells a worker's build and replaces it: they
7
7
  * never change.
8
+ *
9
+ * @sealed
8
10
  */
9
11
  export type WorkerRequests = {
10
12
  "worker/health": [Record<string, never>, WorkerHealth];
@@ -13,6 +15,7 @@ export type WorkerRequests = {
13
15
  "backend/install": [{
14
16
  backend: AgentBackend;
15
17
  }, null];
18
+ "backend/list": [Record<string, never>, AgentBackendInfo[]];
16
19
  "session/create": [{
17
20
  session: AgentSession;
18
21
  }, AgentSessionInfo];
@@ -1,10 +1,11 @@
1
- import { AgentConflictError, AgentInvalidError, AgentNotFoundError, } from "../../api.js";
1
+ import { AgentBackendNotInstalledError, AgentConflictError, AgentInvalidError, AgentNotFoundError, } from "../../api.js";
2
2
  const METHOD_NOT_FOUND = -32601;
3
3
  const INVALID_PARAMS = -32602;
4
4
  const INTERNAL_ERROR = -32603;
5
5
  // Server-defined codes, for the Agent*Errors that aren't bad params.
6
6
  const NOT_FOUND = -32004;
7
7
  const CONFLICT = -32009;
8
+ const BACKEND_NOT_INSTALLED = -32010;
8
9
  export class WorkerMethodNotFoundError extends Error {
9
10
  name = "WorkerMethodNotFoundError";
10
11
  }
@@ -15,6 +16,8 @@ export function encodeWorkerError(error) {
15
16
  return { code: INVALID_PARAMS, message };
16
17
  if (error instanceof AgentNotFoundError)
17
18
  return { code: NOT_FOUND, message };
19
+ if (error instanceof AgentBackendNotInstalledError)
20
+ return { code: BACKEND_NOT_INSTALLED, message };
18
21
  if (error instanceof AgentConflictError)
19
22
  return { code: CONFLICT, message };
20
23
  if (error instanceof WorkerMethodNotFoundError)
@@ -28,5 +31,7 @@ export function decodeWorkerError({ code, message }) {
28
31
  return new AgentNotFoundError(message);
29
32
  if (code === CONFLICT)
30
33
  return new AgentConflictError(message);
34
+ if (code === BACKEND_NOT_INSTALLED)
35
+ return new AgentBackendNotInstalledError(message);
31
36
  return new Error(`worker: ${message}`);
32
37
  }
@@ -8,6 +8,7 @@ export const WORKER_PARAMS = {
8
8
  "worker/health": () => ({}),
9
9
  "worker/shutdown": () => ({}),
10
10
  "backend/install": (params) => ({ backend: backend(params.backend) }),
11
+ "backend/list": () => ({}),
11
12
  "session/create": (params) => ({ session: session(object(params.session, "session")) }),
12
13
  "session/list": () => ({}),
13
14
  "session/update": (params) => ({ session: name(params), limits: limits(object(params.limits, "limits")) }),
@@ -5,11 +5,27 @@ export { EndpointLoginRequiredError, exeDevLoginUrl, sseEvents } from "../../san
5
5
  export type WorkerEndpoint = Pick<SandboxEndpoint, "url" | "credentials">;
6
6
  /**
7
7
  * Connects to a worker through its accessible endpoint (AgentService.exposeAgent: the endpoint
8
- * proxy's "sse" transport), each connection an openSseConnection. Given a function, it asks it for
9
- * the endpoint before every connection, so a proxy that stopped is started again (exposeAgent does
10
- * that) before the client needs it. Runs in a browser as well as on a server. Aborting `signal`
11
- * closes every connection it opened (a stream waiting for the next message included).
8
+ * proxy's "sse" transport), each connection an openSseConnection. Runs in a browser as well as on a
9
+ * server. Aborting `signal` closes every connection it opened (a stream waiting for the next message
10
+ * included).
11
+ *
12
+ * Given a function (one that calls exposeAgent, through the page's server), it asks it once and
13
+ * keeps the endpoint; when a connection fails to open it asks again, which starts a proxy that
14
+ * stopped (an archived and resumed Boat sandbox, a reboot) at the same URL, and retries once. If that
15
+ * fails too it throws EndpointUnreachableError (or EndpointLoginRequiredError, for an exe.dev
16
+ * endpoint). Given an endpoint, it uses it as it is: one that stopped being served just fails.
12
17
  */
13
18
  export declare function workerSseConnector(endpoint: WorkerEndpoint | (() => Promise<WorkerEndpoint>), { signal }?: {
14
19
  signal?: AbortSignal;
15
20
  }): ConnectWorker;
21
+ /**
22
+ * The worker's endpoint didn't answer, even once exposed again. A browser reports that as a CORS
23
+ * error whatever the cause: the page's origin isn't among the endpoint's allowedOrigins (give it to
24
+ * exposeAgent), or nothing answers at the endpoint's URL (the machine is unreachable, or the
25
+ * endpoint was closed).
26
+ */
27
+ export declare class EndpointUnreachableError extends Error {
28
+ readonly url: string;
29
+ name: string;
30
+ constructor(url: string, cause?: unknown);
31
+ }
@@ -1,18 +1,53 @@
1
- import { openSseConnection } from "../../sandbox/sseConnection.js";
1
+ import { EndpointLoginRequiredError, openSseConnection } from "../../sandbox/sseConnection.js";
2
2
  import { workerConnectionOver } from "./workerConnection.js";
3
3
  export { EndpointLoginRequiredError, exeDevLoginUrl, sseEvents } from "../../sandbox/sseConnection.js";
4
4
  /**
5
5
  * Connects to a worker through its accessible endpoint (AgentService.exposeAgent: the endpoint
6
- * proxy's "sse" transport), each connection an openSseConnection. Given a function, it asks it for
7
- * the endpoint before every connection, so a proxy that stopped is started again (exposeAgent does
8
- * that) before the client needs it. Runs in a browser as well as on a server. Aborting `signal`
9
- * closes every connection it opened (a stream waiting for the next message included).
6
+ * proxy's "sse" transport), each connection an openSseConnection. Runs in a browser as well as on a
7
+ * server. Aborting `signal` closes every connection it opened (a stream waiting for the next message
8
+ * included).
9
+ *
10
+ * Given a function (one that calls exposeAgent, through the page's server), it asks it once and
11
+ * keeps the endpoint; when a connection fails to open it asks again, which starts a proxy that
12
+ * stopped (an archived and resumed Boat sandbox, a reboot) at the same URL, and retries once. If that
13
+ * fails too it throws EndpointUnreachableError (or EndpointLoginRequiredError, for an exe.dev
14
+ * endpoint). Given an endpoint, it uses it as it is: one that stopped being served just fails.
10
15
  */
11
16
  export function workerSseConnector(endpoint, { signal } = {}) {
17
+ let kept = null;
18
+ const current = (fresh) => {
19
+ if (typeof endpoint !== "function")
20
+ return Promise.resolve(endpoint);
21
+ if (fresh || !kept) {
22
+ const asked = endpoint();
23
+ kept = asked;
24
+ asked.catch(() => kept === asked && (kept = null));
25
+ }
26
+ return kept;
27
+ };
28
+ async function open() {
29
+ const first = await current(false);
30
+ try {
31
+ return [first, await openSseConnection(first, { signal })];
32
+ }
33
+ catch (error) {
34
+ if (typeof endpoint !== "function" || signal?.aborted)
35
+ throw error;
36
+ }
37
+ // Asked again unless another connection already did since.
38
+ const access = kept && (await kept) !== first ? await kept : await current(true);
39
+ try {
40
+ return [access, await openSseConnection(access, { signal })];
41
+ }
42
+ catch (error) {
43
+ if (signal?.aborted || error instanceof EndpointLoginRequiredError)
44
+ throw error;
45
+ throw new EndpointUnreachableError(access.url, error);
46
+ }
47
+ }
12
48
  return async (onNotification) => {
13
49
  signal?.throwIfAborted();
14
- const access = typeof endpoint === "function" ? await endpoint() : endpoint;
15
- const connection = await openSseConnection(access, { signal });
50
+ const [access, connection] = await open();
16
51
  const decoder = new TextDecoder();
17
52
  const encoder = new TextEncoder();
18
53
  const transport = {
@@ -24,3 +59,18 @@ export function workerSseConnector(endpoint, { signal } = {}) {
24
59
  return workerConnectionOver(transport, access.url.replace(/\/+$/, ""), onNotification);
25
60
  };
26
61
  }
62
+ /**
63
+ * The worker's endpoint didn't answer, even once exposed again. A browser reports that as a CORS
64
+ * error whatever the cause: the page's origin isn't among the endpoint's allowedOrigins (give it to
65
+ * exposeAgent), or nothing answers at the endpoint's URL (the machine is unreachable, or the
66
+ * endpoint was closed).
67
+ */
68
+ export class EndpointUnreachableError extends Error {
69
+ url;
70
+ name = "EndpointUnreachableError";
71
+ constructor(url, cause) {
72
+ super(`the agent's endpoint ${url} didn't answer, even once exposed again (a browser reports this as a CORS error): ` +
73
+ "is the page's origin among its allowedOrigins, and is the machine reachable?", { cause });
74
+ this.url = url;
75
+ }
76
+ }