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 +29 -1
- package/dist/agent/agentService.d.ts +10 -3
- package/dist/agent/agentService.js +47 -18
- package/dist/agent/api.d.ts +32 -5
- package/dist/agent/api.js +7 -0
- package/dist/agent/client.d.ts +1 -1
- package/dist/agent/client.js +1 -1
- package/dist/agent/session/backend.d.ts +3 -1
- package/dist/agent/session/worker/protocol.d.ts +4 -1
- package/dist/agent/session/worker/protocol.js +6 -1
- package/dist/agent/session/worker/workerParams.js +1 -0
- package/dist/agent/session/workerSseConnection.d.ts +20 -4
- package/dist/agent/session/workerSseConnection.js +57 -7
- package/dist/agent/workerBundle.generated.js +1 -1
- package/dist/cli/agent.js +13 -0
- package/dist/cli/services.js +4 -3
- package/dist/dimension/service.d.ts +4 -1
- package/dist/dimension/service.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/portal-gateway/gatewayService.d.ts +4 -1
- package/dist/portal-gateway/gatewayService.js +25 -13
- package/dist/portal-gateway/image.d.ts +1 -1
- package/dist/portal-gateway/image.js +1 -1
- package/dist/portal-gateway/imageFiles.generated.js +1 -1
- package/dist/portal-gateway/portalService.js +61 -29
- package/dist/portal-gateway/upstream.d.ts +22 -11
- package/dist/sandbox/api.d.ts +11 -2
- package/dist/sandbox/endpointProxy.js +16 -1
- package/package.json +1 -1
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
|
-
/**
|
|
12
|
-
|
|
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
|
|
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
|
-
/**
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 })),
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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`.
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
+
}
|
package/dist/agent/client.d.ts
CHANGED
|
@@ -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";
|
package/dist/agent/client.js
CHANGED
|
@@ -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.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 =
|
|
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
|
+
}
|