@axiom-lattice/microsandbox-service 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ import {
2
+ __require,
3
+ buildApp
4
+ } from "./chunk-2W4CTYPX.mjs";
5
+
6
+ // src/lib/server-cli.ts
7
+ function parsePort(raw, source) {
8
+ const value = Number(raw);
9
+ if (!Number.isInteger(value) || value <= 0) {
10
+ throw new Error(`Invalid value for ${source}: ${raw}`);
11
+ }
12
+ return value;
13
+ }
14
+ function parseServerArgs(argv) {
15
+ const parsed = {};
16
+ for (let index = 0; index < argv.length; index += 1) {
17
+ const token = argv[index];
18
+ if (token === "--port") {
19
+ const raw = argv[index + 1];
20
+ if (!raw) {
21
+ throw new Error("Missing value for --port");
22
+ }
23
+ parsed.port = parsePort(raw, "--port");
24
+ index += 1;
25
+ continue;
26
+ }
27
+ if (token === "--host") {
28
+ const raw = argv[index + 1];
29
+ if (!raw) {
30
+ throw new Error("Missing value for --host");
31
+ }
32
+ parsed.host = raw;
33
+ index += 1;
34
+ }
35
+ }
36
+ return parsed;
37
+ }
38
+ function resolveServerConfig({
39
+ args,
40
+ env
41
+ }) {
42
+ return {
43
+ host: args.host ?? env.HOST ?? "0.0.0.0",
44
+ port: args.port ?? (env.PORT ? parsePort(env.PORT, "PORT") : 4002)
45
+ };
46
+ }
47
+
48
+ // src/server.ts
49
+ async function startServer({
50
+ argv = process.argv.slice(2),
51
+ env = process.env,
52
+ app = buildApp()
53
+ } = {}) {
54
+ const config = resolveServerConfig({ args: parseServerArgs(argv), env });
55
+ const shutdown = async (code) => {
56
+ try {
57
+ await app.close();
58
+ process.exit(code);
59
+ } catch (error) {
60
+ console.error("Failed to shutdown microsandbox-service cleanly", error);
61
+ process.exit(1);
62
+ }
63
+ };
64
+ process.once("SIGINT", () => {
65
+ void shutdown(0);
66
+ });
67
+ process.once("SIGTERM", () => {
68
+ void shutdown(0);
69
+ });
70
+ await app.listen({ port: config.port, host: config.host });
71
+ return app;
72
+ }
73
+ if (__require.main === module) {
74
+ void startServer().catch((error) => {
75
+ console.error("Failed to start microsandbox-service", error);
76
+ process.exit(1);
77
+ });
78
+ }
79
+ export {
80
+ startServer
81
+ };
82
+ //# sourceMappingURL=server.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/server-cli.ts","../src/server.ts"],"sourcesContent":["export interface ParsedServerArgs {\n host?: string;\n port?: number;\n}\n\nexport interface ResolvedServerConfig {\n host: string;\n port: number;\n}\n\nfunction parsePort(raw: string, source: string): number {\n const value = Number(raw);\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`Invalid value for ${source}: ${raw}`);\n }\n return value;\n}\n\nexport function parseServerArgs(argv: string[]): ParsedServerArgs {\n const parsed: ParsedServerArgs = {};\n\n for (let index = 0; index < argv.length; index += 1) {\n const token = argv[index];\n\n if (token === \"--port\") {\n const raw = argv[index + 1];\n if (!raw) {\n throw new Error(\"Missing value for --port\");\n }\n parsed.port = parsePort(raw, \"--port\");\n index += 1;\n continue;\n }\n\n if (token === \"--host\") {\n const raw = argv[index + 1];\n if (!raw) {\n throw new Error(\"Missing value for --host\");\n }\n parsed.host = raw;\n index += 1;\n }\n }\n\n return parsed;\n}\n\nexport function resolveServerConfig({\n args,\n env,\n}: {\n args: ParsedServerArgs;\n env: NodeJS.ProcessEnv;\n}): ResolvedServerConfig {\n return {\n host: args.host ?? env.HOST ?? \"0.0.0.0\",\n port: args.port ?? (env.PORT ? parsePort(env.PORT, \"PORT\") : 4002),\n };\n}\n","import type { FastifyInstance } from \"fastify\";\nimport { buildApp } from \"./app\";\nimport { parseServerArgs, resolveServerConfig } from \"./lib/server-cli\";\n\nexport async function startServer({\n argv = process.argv.slice(2),\n env = process.env,\n app = buildApp(),\n}: {\n argv?: string[];\n env?: NodeJS.ProcessEnv;\n app?: Pick<FastifyInstance, \"listen\" | \"close\">;\n} = {}): Promise<Pick<FastifyInstance, \"listen\" | \"close\">> {\n const config = resolveServerConfig({ args: parseServerArgs(argv), env });\n\n const shutdown = async (code: number): Promise<void> => {\n try {\n await app.close();\n process.exit(code);\n } catch (error) {\n console.error(\"Failed to shutdown microsandbox-service cleanly\", error);\n process.exit(1);\n }\n };\n\n process.once(\"SIGINT\", () => {\n void shutdown(0);\n });\n\n process.once(\"SIGTERM\", () => {\n void shutdown(0);\n });\n\n await app.listen({ port: config.port, host: config.host });\n return app;\n}\n\nif (require.main === module) {\n void startServer().catch((error) => {\n console.error(\"Failed to start microsandbox-service\", error);\n process.exit(1);\n });\n}\n"],"mappings":";;;;;;AAUA,SAAS,UAAU,KAAa,QAAwB;AACtD,QAAM,QAAQ,OAAO,GAAG;AACxB,MAAI,CAAC,OAAO,UAAU,KAAK,KAAK,SAAS,GAAG;AAC1C,UAAM,IAAI,MAAM,qBAAqB,MAAM,KAAK,GAAG,EAAE;AAAA,EACvD;AACA,SAAO;AACT;AAEO,SAAS,gBAAgB,MAAkC;AAChE,QAAM,SAA2B,CAAC;AAElC,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAAG;AACnD,UAAM,QAAQ,KAAK,KAAK;AAExB,QAAI,UAAU,UAAU;AACtB,YAAM,MAAM,KAAK,QAAQ,CAAC;AAC1B,UAAI,CAAC,KAAK;AACR,cAAM,IAAI,MAAM,0BAA0B;AAAA,MAC5C;AACA,aAAO,OAAO,UAAU,KAAK,QAAQ;AACrC,eAAS;AACT;AAAA,IACF;AAEA,QAAI,UAAU,UAAU;AACtB,YAAM,MAAM,KAAK,QAAQ,CAAC;AAC1B,UAAI,CAAC,KAAK;AACR,cAAM,IAAI,MAAM,0BAA0B;AAAA,MAC5C;AACA,aAAO,OAAO;AACd,eAAS;AAAA,IACX;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AACF,GAGyB;AACvB,SAAO;AAAA,IACL,MAAM,KAAK,QAAQ,IAAI,QAAQ;AAAA,IAC/B,MAAM,KAAK,SAAS,IAAI,OAAO,UAAU,IAAI,MAAM,MAAM,IAAI;AAAA,EAC/D;AACF;;;ACtDA,eAAsB,YAAY;AAAA,EAChC,OAAO,QAAQ,KAAK,MAAM,CAAC;AAAA,EAC3B,MAAM,QAAQ;AAAA,EACd,MAAM,SAAS;AACjB,IAII,CAAC,GAAuD;AAC1D,QAAM,SAAS,oBAAoB,EAAE,MAAM,gBAAgB,IAAI,GAAG,IAAI,CAAC;AAEvE,QAAM,WAAW,OAAO,SAAgC;AACtD,QAAI;AACF,YAAM,IAAI,MAAM;AAChB,cAAQ,KAAK,IAAI;AAAA,IACnB,SAAS,OAAO;AACd,cAAQ,MAAM,mDAAmD,KAAK;AACtE,cAAQ,KAAK,CAAC;AAAA,IAChB;AAAA,EACF;AAEA,UAAQ,KAAK,UAAU,MAAM;AAC3B,SAAK,SAAS,CAAC;AAAA,EACjB,CAAC;AAED,UAAQ,KAAK,WAAW,MAAM;AAC5B,SAAK,SAAS,CAAC;AAAA,EACjB,CAAC;AAED,QAAM,IAAI,OAAO,EAAE,MAAM,OAAO,MAAM,MAAM,OAAO,KAAK,CAAC;AACzD,SAAO;AACT;AAEA,IAAI,UAAQ,SAAS,QAAQ;AAC3B,OAAK,YAAY,EAAE,MAAM,CAAC,UAAU;AAClC,YAAQ,MAAM,wCAAwC,KAAK;AAC3D,YAAQ,KAAK,CAAC;AAAA,EAChB,CAAC;AACH;","names":[]}
package/jest.config.js ADDED
@@ -0,0 +1,17 @@
1
+ module.exports = {
2
+ preset: "ts-jest",
3
+ testEnvironment: "node",
4
+ roots: ["<rootDir>/src"],
5
+ testMatch: ["**/__tests__/**/*.test.ts", "**/?(*.)+(spec|test).ts"],
6
+ transform: {
7
+ "^.+\\.ts$": [
8
+ "ts-jest",
9
+ {
10
+ isolatedModules: true,
11
+ },
12
+ ],
13
+ },
14
+ collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/__tests__/**/*"],
15
+ coverageReporters: ["text", "lcov", "html"],
16
+ testTimeout: 30000,
17
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@axiom-lattice/microsandbox-service",
3
+ "version": "0.0.1",
4
+ "main": "dist/index.js",
5
+ "module": "dist/index.mjs",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "lattice-microsandbox-service": "./dist/server.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.js"
15
+ },
16
+ "./server": {
17
+ "types": "./dist/server.d.ts",
18
+ "import": "./dist/server.mjs",
19
+ "require": "./dist/server.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@fastify/cors": "^11.0.0",
24
+ "@fastify/multipart": "^9.4.0",
25
+ "@fastify/sensible": "^6.0.3",
26
+ "fastify": "^5.5.0",
27
+ "microsandbox": "^0.3.12",
28
+ "zod": "3.25.76"
29
+ },
30
+ "devDependencies": {
31
+ "@types/jest": "^29.5.14",
32
+ "@types/node": "^20.19.13",
33
+ "jest": "^29.7.0",
34
+ "ts-jest": "^29.4.0",
35
+ "tsup": "^8.5.0",
36
+ "typescript": "^5.8.2"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup src/index.ts src/server.ts --format cjs,esm --dts --clean --sourcemap",
40
+ "start": "node dist/server.js",
41
+ "dev": "tsup src/index.ts src/server.ts --format cjs,esm --dts --watch --sourcemap --onSuccess \"node dist/server.js\"",
42
+ "lint": "tsc --noEmit",
43
+ "test": "jest",
44
+ "test:watch": "jest --watch"
45
+ }
46
+ }
@@ -0,0 +1,251 @@
1
+ import { Mount, Sandbox } from "microsandbox";
2
+ import { SandboxRegistry } from "../services/SandboxRegistry";
3
+ import { MicrosandboxRuntimeService } from "../services/MicrosandboxRuntimeService";
4
+
5
+ jest.mock("microsandbox", () => ({
6
+ Mount: {
7
+ bind: jest.fn((source: string, options?: unknown) => ({ kind: "bind", source, options })),
8
+ named: jest.fn((name: string, options?: unknown) => ({ kind: "named", name, options })),
9
+ tmpfs: jest.fn((options?: unknown) => ({ kind: "tmpfs", options })),
10
+ },
11
+ Sandbox: {
12
+ get: jest.fn(),
13
+ start: jest.fn(),
14
+ createDetached: jest.fn(),
15
+ remove: jest.fn(),
16
+ },
17
+ }));
18
+
19
+ describe("MicrosandboxRuntimeService", () => {
20
+ afterEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+
24
+ it("translates bind, named, and tmpfs volumes on ensure", async () => {
25
+ const native = { fs: jest.fn(), execWithConfig: jest.fn() };
26
+
27
+ (Sandbox.get as jest.Mock).mockRejectedValue(new Error("not found"));
28
+ (Sandbox.createDetached as jest.Mock).mockResolvedValue(native);
29
+
30
+ const service = new MicrosandboxRuntimeService({ registry: new SandboxRegistry() });
31
+
32
+ await service.ensureSandbox("tenant-a", {
33
+ image: "python:3.11-slim",
34
+ volumes: {
35
+ "/data/src": { type: "bind", source: "/srv/src", readonly: true },
36
+ "/data/cache": { type: "named", name: "cache-vol" },
37
+ "/tmp/scratch": { type: "tmpfs", sizeMib: 64 },
38
+ },
39
+ });
40
+
41
+ expect(Mount.bind).toHaveBeenCalledWith("/srv/src", { readonly: true });
42
+ expect(Mount.named).toHaveBeenCalledWith("cache-vol", { readonly: false });
43
+ expect(Mount.tmpfs).toHaveBeenCalledWith({ sizeMib: 64 });
44
+ expect(Sandbox.createDetached).toHaveBeenCalledWith(
45
+ expect.objectContaining({
46
+ name: "tenant-a",
47
+ image: "python:3.11-slim",
48
+ })
49
+ );
50
+ });
51
+
52
+ it("startSandbox clears the cached handle and re-ensures the sandbox", async () => {
53
+ const registry = {
54
+ delete: jest.fn(),
55
+ ensure: jest.fn().mockResolvedValue({ id: "native" }),
56
+ } as unknown as SandboxRegistry;
57
+
58
+ (Sandbox.start as jest.Mock).mockResolvedValue({ id: "started" });
59
+
60
+ const service = new MicrosandboxRuntimeService({ registry });
61
+
62
+ await expect(service.startSandbox("tenant-a")).resolves.toEqual({
63
+ name: "tenant-a",
64
+ status: "running",
65
+ });
66
+
67
+ expect(Sandbox.start).toHaveBeenCalledWith("tenant-a");
68
+ expect(registry.delete).toHaveBeenCalledWith("tenant-a");
69
+ expect(registry.ensure).toHaveBeenCalledWith("tenant-a");
70
+ });
71
+
72
+ it("stopSandbox clears the registry state", async () => {
73
+ const native = { stop: jest.fn().mockResolvedValue(undefined) };
74
+ const registry = {
75
+ get: jest.fn().mockReturnValue(native),
76
+ delete: jest.fn(),
77
+ } as unknown as SandboxRegistry;
78
+
79
+ const service = new MicrosandboxRuntimeService({ registry });
80
+
81
+ await expect(service.stopSandbox("tenant-a")).resolves.toEqual({
82
+ name: "tenant-a",
83
+ status: "stopped",
84
+ });
85
+
86
+ expect(native.stop).toHaveBeenCalledTimes(1);
87
+ expect(registry.delete).toHaveBeenCalledWith("tenant-a");
88
+ });
89
+
90
+ it("killSandbox clears the registry state", async () => {
91
+ const native = { kill: jest.fn().mockResolvedValue(undefined) };
92
+ const registry = {
93
+ get: jest.fn().mockReturnValue(native),
94
+ delete: jest.fn(),
95
+ } as unknown as SandboxRegistry;
96
+
97
+ const service = new MicrosandboxRuntimeService({ registry });
98
+
99
+ await expect(service.killSandbox("tenant-a")).resolves.toEqual({
100
+ name: "tenant-a",
101
+ status: "unknown",
102
+ });
103
+
104
+ expect(native.kill).toHaveBeenCalledTimes(1);
105
+ expect(registry.delete).toHaveBeenCalledWith("tenant-a");
106
+ });
107
+
108
+ it("deleteSandbox clears registry state after removing the sandbox", async () => {
109
+ const native = { kill: jest.fn().mockResolvedValue(undefined) };
110
+ const registry = {
111
+ get: jest.fn().mockReturnValue(native),
112
+ delete: jest.fn(),
113
+ } as unknown as SandboxRegistry;
114
+
115
+ const service = new MicrosandboxRuntimeService({ registry });
116
+
117
+ await expect(service.deleteSandbox("tenant-a")).resolves.toEqual({
118
+ name: "tenant-a",
119
+ status: "unknown",
120
+ });
121
+
122
+ expect(native.kill).toHaveBeenCalledTimes(1);
123
+ expect(Sandbox.remove).toHaveBeenCalledWith("tenant-a");
124
+ expect(registry.delete).toHaveBeenCalledWith("tenant-a");
125
+ });
126
+
127
+ it("deleteSandbox still removes the sandbox when no cached handle exists", async () => {
128
+ const registry = {
129
+ get: jest.fn().mockImplementation(() => {
130
+ throw new Error("missing");
131
+ }),
132
+ delete: jest.fn(),
133
+ } as unknown as SandboxRegistry;
134
+
135
+ const service = new MicrosandboxRuntimeService({ registry });
136
+
137
+ await expect(service.deleteSandbox("tenant-b")).resolves.toEqual({
138
+ name: "tenant-b",
139
+ status: "unknown",
140
+ });
141
+
142
+ expect(Sandbox.remove).toHaveBeenCalledWith("tenant-b");
143
+ expect(registry.delete).toHaveBeenCalledWith("tenant-b");
144
+ });
145
+
146
+ it("getStatus does not create a missing sandbox", async () => {
147
+ const registry = {
148
+ get: jest.fn().mockImplementation(() => {
149
+ throw new Error("missing");
150
+ }),
151
+ ensure: jest.fn(),
152
+ } as unknown as SandboxRegistry;
153
+
154
+ const service = new MicrosandboxRuntimeService({ registry });
155
+
156
+ await expect(service.getStatus("tenant-missing")).resolves.toEqual({
157
+ name: "tenant-missing",
158
+ status: "unknown",
159
+ });
160
+
161
+ expect(registry.ensure).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("stopSandbox reconnects by name when the cache is empty", async () => {
165
+ const native = { stop: jest.fn().mockResolvedValue(undefined) };
166
+ const registry = {
167
+ get: jest.fn().mockImplementation(() => {
168
+ throw new Error("missing");
169
+ }),
170
+ ensure: jest.fn().mockResolvedValue(native),
171
+ delete: jest.fn(),
172
+ } as unknown as SandboxRegistry;
173
+
174
+ const service = new MicrosandboxRuntimeService({ registry });
175
+
176
+ await expect(service.stopSandbox("tenant-a")).resolves.toEqual({
177
+ name: "tenant-a",
178
+ status: "stopped",
179
+ });
180
+
181
+ expect(registry.ensure).toHaveBeenCalledWith("tenant-a");
182
+ expect(native.stop).toHaveBeenCalledTimes(1);
183
+ expect(registry.delete).toHaveBeenCalledWith("tenant-a");
184
+ });
185
+
186
+ it("killSandbox reconnects by name when the cache is empty", async () => {
187
+ const native = { kill: jest.fn().mockResolvedValue(undefined) };
188
+ const registry = {
189
+ get: jest.fn().mockImplementation(() => {
190
+ throw new Error("missing");
191
+ }),
192
+ ensure: jest.fn().mockResolvedValue(native),
193
+ delete: jest.fn(),
194
+ } as unknown as SandboxRegistry;
195
+
196
+ const service = new MicrosandboxRuntimeService({ registry });
197
+
198
+ await expect(service.killSandbox("tenant-a")).resolves.toEqual({
199
+ name: "tenant-a",
200
+ status: "unknown",
201
+ });
202
+
203
+ expect(registry.ensure).toHaveBeenCalledWith("tenant-a");
204
+ expect(native.kill).toHaveBeenCalledTimes(1);
205
+ expect(registry.delete).toHaveBeenCalledWith("tenant-a");
206
+ });
207
+
208
+ it("searchInFile returns an empty match result when grep finds nothing", async () => {
209
+ const native = {
210
+ execWithConfig: jest.fn().mockRejectedValue({ code: 1 }),
211
+ };
212
+ const registry = {
213
+ get: jest.fn().mockReturnValue(native),
214
+ } as unknown as SandboxRegistry;
215
+
216
+ const service = new MicrosandboxRuntimeService({ registry });
217
+
218
+ await expect(service.searchInFile("tenant-a", "/tmp/test.txt", "hello")).resolves.toEqual({
219
+ matches: [],
220
+ });
221
+ });
222
+
223
+ it("forwards recursive list requests and preserves nested paths plus binary upload/download payloads", async () => {
224
+ const binary = Buffer.from([0, 255, 1]);
225
+ const fs = {
226
+ list: jest.fn().mockResolvedValue([{ path: "/tmp/subdir/file.txt", kind: "file" }]),
227
+ write: jest.fn().mockResolvedValue(undefined),
228
+ read: jest.fn().mockResolvedValue(binary),
229
+ };
230
+ const registry = {
231
+ get: jest.fn().mockReturnValue({ fs: () => fs }),
232
+ } as unknown as SandboxRegistry;
233
+
234
+ const service = new MicrosandboxRuntimeService({ registry });
235
+
236
+ await expect(service.listPath("tenant-a", "/tmp", true)).resolves.toEqual({
237
+ entries: [{ path: "/tmp/subdir/file.txt", type: "file" }],
238
+ });
239
+ await expect(service.uploadFile("tenant-a", "/tmp/blob.bin", binary.toString("base64"))).resolves.toEqual({
240
+ path: "/tmp/blob.bin",
241
+ });
242
+ await expect(service.downloadFile("tenant-a", "/tmp/blob.bin")).resolves.toEqual({
243
+ path: "/tmp/blob.bin",
244
+ contentBase64: binary.toString("base64"),
245
+ });
246
+
247
+ expect(fs.list).toHaveBeenCalledWith("/tmp", { recursive: true });
248
+ expect(fs.write).toHaveBeenCalledWith("/tmp/blob.bin", binary);
249
+ expect(fs.read).toHaveBeenCalledWith("/tmp/blob.bin");
250
+ });
251
+ });
@@ -0,0 +1,101 @@
1
+ import { Sandbox } from "microsandbox";
2
+ import { SandboxRegistry } from "../services/SandboxRegistry";
3
+
4
+ jest.mock("microsandbox", () => ({
5
+ Sandbox: {
6
+ get: jest.fn(),
7
+ start: jest.fn(),
8
+ createDetached: jest.fn(),
9
+ remove: jest.fn(),
10
+ },
11
+ }));
12
+
13
+ describe("SandboxRegistry", () => {
14
+ afterEach(() => {
15
+ jest.clearAllMocks();
16
+ });
17
+
18
+ it("reuses a running sandbox connection", async () => {
19
+ const native = { id: "native" };
20
+ const connect = jest.fn().mockResolvedValue(native);
21
+
22
+ (Sandbox.get as jest.Mock).mockResolvedValue({ status: "running", connect });
23
+
24
+ const registry = new SandboxRegistry();
25
+ const first = await registry.ensure("tenant-a");
26
+ const second = await registry.ensure("tenant-a");
27
+
28
+ expect(first).toBe(second);
29
+ expect(connect).toHaveBeenCalledTimes(1);
30
+ });
31
+
32
+ it("reconnects a stopped sandbox by starting it", async () => {
33
+ const native = { id: "started" };
34
+
35
+ (Sandbox.get as jest.Mock).mockResolvedValue({ status: "stopped" });
36
+ (Sandbox.start as jest.Mock).mockResolvedValue(native);
37
+
38
+ const registry = new SandboxRegistry();
39
+ const sandbox = await registry.ensure("tenant-b");
40
+
41
+ expect(sandbox).toBe(native);
42
+ expect(Sandbox.start).toHaveBeenCalledWith("tenant-b");
43
+ expect(Sandbox.createDetached).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("removes crashed sandboxes and recreates them", async () => {
47
+ const native = { id: "created" };
48
+
49
+ (Sandbox.get as jest.Mock).mockResolvedValue({ status: "crashed" });
50
+ (Sandbox.createDetached as jest.Mock).mockResolvedValue(native);
51
+
52
+ const registry = new SandboxRegistry();
53
+ const sandbox = await registry.ensure("tenant-c", { image: "python:3.11-slim" });
54
+
55
+ expect(sandbox).toBe(native);
56
+ expect(Sandbox.remove).toHaveBeenCalledWith("tenant-c");
57
+ expect(Sandbox.createDetached).toHaveBeenCalledWith({
58
+ name: "tenant-c",
59
+ image: "python:3.11-slim",
60
+ });
61
+ });
62
+
63
+ it("removes draining sandboxes and recreates them", async () => {
64
+ const native = { id: "created" };
65
+
66
+ (Sandbox.get as jest.Mock).mockResolvedValue({ status: "draining" });
67
+ (Sandbox.createDetached as jest.Mock).mockResolvedValue(native);
68
+
69
+ const registry = new SandboxRegistry();
70
+
71
+ await registry.ensure("tenant-d");
72
+
73
+ expect(Sandbox.remove).toHaveBeenCalledWith("tenant-d");
74
+ expect(Sandbox.createDetached).toHaveBeenCalledWith({
75
+ name: "tenant-d",
76
+ image: "python:3.11-slim",
77
+ });
78
+ });
79
+
80
+ it("creates a fresh handle after cache invalidation", async () => {
81
+ const firstNative = { id: "first" };
82
+ const secondNative = { id: "second" };
83
+ const firstConnect = jest.fn().mockResolvedValue(firstNative);
84
+ const secondConnect = jest.fn().mockResolvedValue(secondNative);
85
+
86
+ (Sandbox.get as jest.Mock)
87
+ .mockResolvedValueOnce({ status: "running", connect: firstConnect })
88
+ .mockResolvedValueOnce({ status: "running", connect: secondConnect });
89
+
90
+ const registry = new SandboxRegistry();
91
+ const first = await registry.ensure("tenant-e");
92
+ registry.delete("tenant-e");
93
+ const second = await registry.ensure("tenant-e");
94
+
95
+ expect(first).toBe(firstNative);
96
+ expect(second).toBe(secondNative);
97
+ expect(second).not.toBe(first);
98
+ expect(firstConnect).toHaveBeenCalledTimes(1);
99
+ expect(secondConnect).toHaveBeenCalledTimes(1);
100
+ });
101
+ });