@alchemy.run/archil 0.0.0 → 2.0.0-beta.86

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.
Files changed (184) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +57 -4
  3. package/lib/AWS/S3Disk.d.ts +68 -0
  4. package/lib/AWS/S3Disk.d.ts.map +1 -0
  5. package/lib/AWS/S3Disk.js +100 -0
  6. package/lib/AWS/S3Disk.js.map +1 -0
  7. package/lib/AWS/index.d.ts +2 -0
  8. package/lib/AWS/index.d.ts.map +1 -0
  9. package/lib/AWS/index.js +2 -0
  10. package/lib/AWS/index.js.map +1 -0
  11. package/lib/ApiToken.d.ts +63 -0
  12. package/lib/ApiToken.d.ts.map +1 -0
  13. package/lib/ApiToken.js +121 -0
  14. package/lib/ApiToken.js.map +1 -0
  15. package/lib/ArchilBindings.d.ts +65 -0
  16. package/lib/ArchilBindings.d.ts.map +1 -0
  17. package/lib/ArchilBindings.js +128 -0
  18. package/lib/ArchilBindings.js.map +1 -0
  19. package/lib/AuthProvider.d.ts +40 -0
  20. package/lib/AuthProvider.d.ts.map +1 -0
  21. package/lib/AuthProvider.js +87 -0
  22. package/lib/AuthProvider.js.map +1 -0
  23. package/lib/Bootstrap.d.ts +8 -0
  24. package/lib/Bootstrap.d.ts.map +1 -0
  25. package/lib/Bootstrap.js +27 -0
  26. package/lib/Bootstrap.js.map +1 -0
  27. package/lib/Branch.d.ts +101 -0
  28. package/lib/Branch.d.ts.map +1 -0
  29. package/lib/Branch.js +117 -0
  30. package/lib/Branch.js.map +1 -0
  31. package/lib/BranchDisk.d.ts +42 -0
  32. package/lib/BranchDisk.d.ts.map +1 -0
  33. package/lib/BranchDisk.js +14 -0
  34. package/lib/BranchDisk.js.map +1 -0
  35. package/lib/Checkpoint.d.ts +78 -0
  36. package/lib/Checkpoint.d.ts.map +1 -0
  37. package/lib/Checkpoint.js +95 -0
  38. package/lib/Checkpoint.js.map +1 -0
  39. package/lib/Clients.d.ts +369 -0
  40. package/lib/Clients.d.ts.map +1 -0
  41. package/lib/Clients.js +334 -0
  42. package/lib/Clients.js.map +1 -0
  43. package/lib/ConnectSandbox.d.ts +69 -0
  44. package/lib/ConnectSandbox.d.ts.map +1 -0
  45. package/lib/ConnectSandbox.js +21 -0
  46. package/lib/ConnectSandbox.js.map +1 -0
  47. package/lib/Contributions.d.ts +7 -0
  48. package/lib/Contributions.d.ts.map +1 -0
  49. package/lib/Contributions.js +6 -0
  50. package/lib/Contributions.js.map +1 -0
  51. package/lib/CreateDisk.d.ts +90 -0
  52. package/lib/CreateDisk.d.ts.map +1 -0
  53. package/lib/CreateDisk.js +47 -0
  54. package/lib/CreateDisk.js.map +1 -0
  55. package/lib/CreateImage.d.ts +42 -0
  56. package/lib/CreateImage.d.ts.map +1 -0
  57. package/lib/CreateImage.js +19 -0
  58. package/lib/CreateImage.js.map +1 -0
  59. package/lib/CreateSandbox.d.ts +104 -0
  60. package/lib/CreateSandbox.d.ts.map +1 -0
  61. package/lib/CreateSandbox.js +98 -0
  62. package/lib/CreateSandbox.js.map +1 -0
  63. package/lib/Credentials.d.ts +44 -0
  64. package/lib/Credentials.d.ts.map +1 -0
  65. package/lib/Credentials.js +57 -0
  66. package/lib/Credentials.js.map +1 -0
  67. package/lib/Disk.d.ts +283 -0
  68. package/lib/Disk.d.ts.map +1 -0
  69. package/lib/Disk.js +294 -0
  70. package/lib/Disk.js.map +1 -0
  71. package/lib/DiskObjectsHttp.d.ts +20 -0
  72. package/lib/DiskObjectsHttp.d.ts.map +1 -0
  73. package/lib/DiskObjectsHttp.js +51 -0
  74. package/lib/DiskObjectsHttp.js.map +1 -0
  75. package/lib/DiskUser.d.ts +189 -0
  76. package/lib/DiskUser.d.ts.map +1 -0
  77. package/lib/DiskUser.js +243 -0
  78. package/lib/DiskUser.js.map +1 -0
  79. package/lib/ExecDisk.d.ts +57 -0
  80. package/lib/ExecDisk.d.ts.map +1 -0
  81. package/lib/ExecDisk.js +3 -0
  82. package/lib/ExecDisk.js.map +1 -0
  83. package/lib/ExecDiskHttp.d.ts +5 -0
  84. package/lib/ExecDiskHttp.d.ts.map +1 -0
  85. package/lib/ExecDiskHttp.js +18 -0
  86. package/lib/ExecDiskHttp.js.map +1 -0
  87. package/lib/ExecDisks.d.ts +54 -0
  88. package/lib/ExecDisks.d.ts.map +1 -0
  89. package/lib/ExecDisks.js +37 -0
  90. package/lib/ExecDisks.js.map +1 -0
  91. package/lib/Image.d.ts +188 -0
  92. package/lib/Image.d.ts.map +1 -0
  93. package/lib/Image.js +191 -0
  94. package/lib/Image.js.map +1 -0
  95. package/lib/InferEnv.d.ts +26 -0
  96. package/lib/InferEnv.d.ts.map +1 -0
  97. package/lib/InferEnv.js +1 -0
  98. package/lib/InferEnv.js.map +1 -0
  99. package/lib/MountDisk.d.ts +190 -0
  100. package/lib/MountDisk.d.ts.map +1 -0
  101. package/lib/MountDisk.js +227 -0
  102. package/lib/MountDisk.js.map +1 -0
  103. package/lib/Providers.d.ts +25 -0
  104. package/lib/Providers.d.ts.map +1 -0
  105. package/lib/Providers.js +48 -0
  106. package/lib/Providers.js.map +1 -0
  107. package/lib/ReadDisk.d.ts +55 -0
  108. package/lib/ReadDisk.d.ts.map +1 -0
  109. package/lib/ReadDisk.js +3 -0
  110. package/lib/ReadDisk.js.map +1 -0
  111. package/lib/ReadWriteDisk.d.ts +26 -0
  112. package/lib/ReadWriteDisk.d.ts.map +1 -0
  113. package/lib/ReadWriteDisk.js +3 -0
  114. package/lib/ReadWriteDisk.js.map +1 -0
  115. package/lib/Sandbox.d.ts +507 -0
  116. package/lib/Sandbox.d.ts.map +1 -0
  117. package/lib/Sandbox.js +729 -0
  118. package/lib/Sandbox.js.map +1 -0
  119. package/lib/SandboxOptions.d.ts +185 -0
  120. package/lib/SandboxOptions.d.ts.map +1 -0
  121. package/lib/SandboxOptions.js +102 -0
  122. package/lib/SandboxOptions.js.map +1 -0
  123. package/lib/SandboxPortToken.d.ts +64 -0
  124. package/lib/SandboxPortToken.d.ts.map +1 -0
  125. package/lib/SandboxPortToken.js +77 -0
  126. package/lib/SandboxPortToken.js.map +1 -0
  127. package/lib/WriteDisk.d.ts +37 -0
  128. package/lib/WriteDisk.d.ts.map +1 -0
  129. package/lib/WriteDisk.js +3 -0
  130. package/lib/WriteDisk.js.map +1 -0
  131. package/lib/index.d.ts +29 -0
  132. package/lib/index.d.ts.map +1 -0
  133. package/lib/index.js +28 -0
  134. package/lib/index.js.map +1 -0
  135. package/lib/internal/DeployerKey.d.ts +21 -0
  136. package/lib/internal/DeployerKey.d.ts.map +1 -0
  137. package/lib/internal/DeployerKey.js +34 -0
  138. package/lib/internal/DeployerKey.js.map +1 -0
  139. package/lib/internal/archil.d.ts +87 -0
  140. package/lib/internal/archil.d.ts.map +1 -0
  141. package/lib/internal/archil.js +154 -0
  142. package/lib/internal/archil.js.map +1 -0
  143. package/lib/internal/hosted.d.ts +90 -0
  144. package/lib/internal/hosted.d.ts.map +1 -0
  145. package/lib/internal/hosted.js +291 -0
  146. package/lib/internal/hosted.js.map +1 -0
  147. package/package.json +79 -3
  148. package/src/AWS/S3Disk.ts +125 -0
  149. package/src/AWS/index.ts +1 -0
  150. package/src/ApiToken.ts +168 -0
  151. package/src/ArchilBindings.ts +179 -0
  152. package/src/AuthProvider.ts +122 -0
  153. package/src/Bootstrap.ts +54 -0
  154. package/src/Branch.ts +199 -0
  155. package/src/BranchDisk.ts +57 -0
  156. package/src/Checkpoint.ts +145 -0
  157. package/src/Clients.ts +954 -0
  158. package/src/ConnectSandbox.ts +91 -0
  159. package/src/Contributions.ts +9 -0
  160. package/src/CreateDisk.ts +165 -0
  161. package/src/CreateImage.ts +73 -0
  162. package/src/CreateSandbox.ts +258 -0
  163. package/src/Credentials.ts +101 -0
  164. package/src/Disk.ts +468 -0
  165. package/src/DiskObjectsHttp.ts +64 -0
  166. package/src/DiskUser.ts +369 -0
  167. package/src/ExecDisk.ts +62 -0
  168. package/src/ExecDiskHttp.ts +25 -0
  169. package/src/ExecDisks.ts +100 -0
  170. package/src/Image.ts +316 -0
  171. package/src/InferEnv.ts +27 -0
  172. package/src/MountDisk.ts +466 -0
  173. package/src/Providers.ts +75 -0
  174. package/src/ReadDisk.ts +61 -0
  175. package/src/ReadWriteDisk.ts +31 -0
  176. package/src/Sandbox.ts +1064 -0
  177. package/src/SandboxOptions.ts +230 -0
  178. package/src/SandboxPortToken.ts +125 -0
  179. package/src/WriteDisk.ts +42 -0
  180. package/src/globals.d.ts +2 -0
  181. package/src/index.ts +59 -0
  182. package/src/internal/DeployerKey.ts +63 -0
  183. package/src/internal/archil.ts +273 -0
  184. package/src/internal/hosted.ts +402 -0
package/src/Sandbox.ts ADDED
@@ -0,0 +1,1064 @@
1
+ import * as BuildPlan from "@alchemy.run/docker/BuildPlan";
2
+ import * as archil from "@distilled.cloud/archil";
3
+ import * as Process from "@distilled.cloud/archil/Process";
4
+ import { Unowned } from "alchemy/AdoptPolicy";
5
+ import * as Bundle from "alchemy/Bundle/Bundle";
6
+ import { deepEqual, isResolved } from "alchemy/Diff";
7
+ import {
8
+ checkMountSupport,
9
+ collectMountCapabilities,
10
+ mountEnv,
11
+ mountInstallLines,
12
+ type MountCapability,
13
+ } from "alchemy/Mount";
14
+ import { Platform, type Main, type PlatformProps } from "alchemy/Platform";
15
+ import * as Provider from "alchemy/Provider";
16
+ import type { LookupMethod, Resource, ResourceBinding } from "alchemy/Resource";
17
+ import {
18
+ createHostRuntimeContext,
19
+ type HostRuntimeContext,
20
+ type ServerHost,
21
+ } from "alchemy/Runtime/Process";
22
+ import { Stack } from "alchemy/Stack";
23
+ import { sha256Object } from "alchemy/Util/sha256";
24
+ import * as Data from "effect/Data";
25
+ import * as Effect from "effect/Effect";
26
+ import type * as Redacted from "effect/Redacted";
27
+ import type { Disk } from "./Disk.ts";
28
+ import {
29
+ capabilities as imageCapabilities,
30
+ ImageRequiresBuild,
31
+ publishPlan,
32
+ pullImage,
33
+ type ImagePush,
34
+ } from "./Image.ts";
35
+ import {
36
+ deletableSandboxStates,
37
+ findSandboxByName,
38
+ getSandbox,
39
+ isAlchemyName,
40
+ isRuntimeObjectName,
41
+ listSandboxes,
42
+ sandboxName,
43
+ waitForSandbox,
44
+ waitForSandboxGone,
45
+ waitForSandboxStopped,
46
+ } from "./internal/archil.ts";
47
+ import {
48
+ bundleProgram,
49
+ deployProgram,
50
+ removeProgram,
51
+ type ProgramBuildOptions,
52
+ type ProgramExtraFile,
53
+ } from "./internal/hosted.ts";
54
+ import type { Providers } from "./Providers.ts";
55
+ import {
56
+ egressIdentity,
57
+ MountDiskUnresolved,
58
+ mountDiskId,
59
+ mountIdentity,
60
+ splitArchilEgress,
61
+ toEgressPolicy,
62
+ toEnv,
63
+ toSandboxMount,
64
+ type DiskMount,
65
+ type DiskMountOptions,
66
+ type SandboxEgress,
67
+ type SandboxEndpoint,
68
+ } from "./SandboxOptions.ts";
69
+
70
+ /** A disk a binding mounts into a program sandbox (`MountDisk` inside one). */
71
+
72
+ /** A sandbox's `run` command exited non-zero. */
73
+ export class SandboxSetupFailed extends Data.TaggedError("Archil.SandboxSetupFailed")<{
74
+ readonly sandbox: string;
75
+ readonly command: string;
76
+ readonly exitCode: number;
77
+ readonly stderr: string;
78
+ }> {
79
+ override get message() {
80
+ return `\`${this.command}\` exited ${this.exitCode} in sandbox ${this.sandbox}: ${this.stderr.slice(-2000)}`;
81
+ }
82
+ }
83
+
84
+ export interface SandboxMountBinding extends DiskMountOptions {
85
+ readonly diskId: string;
86
+ }
87
+
88
+ /**
89
+ * What bindings attach to a sandbox: its program's environment, disks it
90
+ * mounts natively (`SandboxMountBinding`), and filesystem clients other
91
+ * mount bindings install into its image (`MountCapability`, from
92
+ * `alchemy/Mount`).
93
+ */
94
+ export interface SandboxBinding {
95
+ env?: Record<string, any>;
96
+ mounts?: ReadonlyArray<SandboxMountBinding | MountCapability>;
97
+ }
98
+
99
+ export interface SandboxProps extends PlatformProps {
100
+ /**
101
+ * Sandbox name, unique within your account: lowercase letters, digits
102
+ * and `-`, at most 63 characters. If omitted, a unique name is generated
103
+ * from the stack, stage, and logical ID. Changing it replaces the sandbox.
104
+ */
105
+ name?: string;
106
+ /**
107
+ * Image id from an {@link Image} (e.g. `image.imageId`), for a private
108
+ * image or a pinned digest. Exclusive with `baseImage`. Changing it
109
+ * replaces the sandbox.
110
+ */
111
+ image?: string;
112
+ /**
113
+ * Public OCI image reference with a `linux/amd64` manifest. Exclusive
114
+ * with `image`. Changing it replaces the sandbox.
115
+ * @default "node:24-bookworm"
116
+ */
117
+ baseImage?: string;
118
+ /** vCPUs, 1–32. Changing it replaces the sandbox. @default 1 */
119
+ vcpuCount?: number;
120
+ /** Memory in MiB, 256–65536. Changing it replaces the sandbox. @default 2048 */
121
+ memSizeMiB?: number;
122
+ /**
123
+ * TCP ports to serve publicly. Updated in place. When set, it is the
124
+ * whole list (plus the program's `port`): ports not in it are unexposed.
125
+ */
126
+ ports?: number[];
127
+ /**
128
+ * Let services inside the sandbox publish their own ingress.
129
+ * Changing it replaces the sandbox. @default false
130
+ */
131
+ enableServiceIngress?: boolean;
132
+ /**
133
+ * Environment variables. With `main`, they are the program's environment
134
+ * and a change restarts it. Without `main`, every process in the sandbox
135
+ * gets them, and a change replaces the sandbox.
136
+ */
137
+ env?: Record<string, string | Redacted.Redacted<string>>;
138
+ /** Outbound network policy. Updated in place. */
139
+ network?: { egress?: SandboxEgress };
140
+ /**
141
+ * Disks to mount: a declared `Archil.Disk` (or the Effect that declares
142
+ * it) at a `path`. Updated in place: the sandbox stops, then starts with
143
+ * the new mounts, keeping its root filesystem.
144
+ */
145
+ mounts?: DiskMount[];
146
+ /**
147
+ * Seconds a powered-on session may run before the sandbox pauses
148
+ * (at most 24 hours). Changing it replaces the sandbox.
149
+ */
150
+ maxTtlSeconds?: number;
151
+ /**
152
+ * Seconds without a process connection before the sandbox pauses.
153
+ * `0` disables idle expiry. Updated in place.
154
+ */
155
+ idleTtlSeconds?: number;
156
+ /** Concurrent processes allowed. Changing it replaces the sandbox. */
157
+ maxConcurrentExecs?: number;
158
+ /**
159
+ * Shell commands run in the sandbox, in order, once it is created: clone
160
+ * a repository, install packages, warm a cache. A non-zero exit fails the
161
+ * deploy with `SandboxSetupFailed`. Changing them replaces the sandbox,
162
+ * so it starts again from its image and runs the new commands. Fork the
163
+ * result at run time with `Archil.CreateSandbox(sandbox)`.
164
+ */
165
+ run?: string[];
166
+ /**
167
+ * A program to run in the sandbox. In the Effect form, `import.meta.url`
168
+ * (the file is both the bundle entry and the program). In the async form,
169
+ * a path to a module whose default export has `fetch(request, env)`.
170
+ * A code change uploads it and restarts the program in place.
171
+ */
172
+ main?: string;
173
+ /** Named export to load from `main`. @default "default" */
174
+ handler?: string;
175
+ /**
176
+ * TCP port the program listens on, passed to it as `PORT`. Alchemy
177
+ * exposes it publicly and returns its URL as `url`.
178
+ * @default 3000
179
+ */
180
+ port?: number;
181
+ /**
182
+ * Bundler options for `main` (rolldown `input` / `output`), and
183
+ * `install`: packages to `npm install` next to the program instead of
184
+ * bundling them.
185
+ */
186
+ build?: ProgramBuildOptions;
187
+ /**
188
+ * Host files or directories uploaded with the program, e.g. a site's
189
+ * built assets. With `extraFiles`, an async `main` is treated as a
190
+ * complete Node program and uploaded as built instead of bundled.
191
+ * Their content is part of `code.hash`, so a change redeploys.
192
+ */
193
+ extraFiles?: ReadonlyArray<ProgramExtraFile>;
194
+ /**
195
+ * Where Alchemy pushes the sandbox's derived image, for Archil to pull.
196
+ * Only needed when a binding installs a filesystem client (an
197
+ * `alchemy/Mount` capability) into the program's image: Alchemy builds
198
+ * the base image plus those install lines with your local Docker.
199
+ */
200
+ push?: ImagePush;
201
+ }
202
+
203
+ export interface SandboxAttributes {
204
+ /** Sandbox id. */
205
+ sandboxId: string;
206
+ /** Sandbox name. */
207
+ name: string;
208
+ /** Lifecycle state when last observed, e.g. `running` or `paused`. */
209
+ status: string;
210
+ /** The image reference the sandbox boots. */
211
+ baseImage: string;
212
+ /** Digest of the booted image. */
213
+ imageDigest: string | undefined;
214
+ vcpuCount: number;
215
+ memSizeMiB: number;
216
+ /** Public hostnames of the exposed ports. */
217
+ endpoints: SandboxEndpoint[];
218
+ /** `https://` URL of the program's port, or of the first exposed port. */
219
+ url: string | undefined;
220
+ /** The port the program listens on, when the sandbox runs one. */
221
+ port: number | undefined;
222
+ /** Content hash of the deployed program, when the sandbox runs one. */
223
+ code: { hash: string } | undefined;
224
+ /**
225
+ * The image Alchemy built for the sandbox (its base plus the install
226
+ * lines its bindings contribute), when it needed one.
227
+ */
228
+ derivedImage: { imageId: string; hash: string } | undefined;
229
+ /** Hash of the `run` commands the sandbox was set up with. */
230
+ setupHash: string | undefined;
231
+ }
232
+
233
+ export type Sandbox = Resource<
234
+ "Archil.Sandbox",
235
+ SandboxProps,
236
+ SandboxAttributes,
237
+ SandboxBinding,
238
+ Providers,
239
+ SandboxLookup
240
+ >;
241
+
242
+ /**
243
+ * Props for {@link Sandbox.lookup}: find a sandbox this stack
244
+ * doesn't manage by its name.
245
+ */
246
+ export interface SandboxLookup {
247
+ /** Sandbox name. */
248
+ name: string;
249
+ }
250
+
251
+ export type SandboxShape = Main<ServerHost>;
252
+
253
+ /**
254
+ * An Archil sandbox: a Firecracker microVM with disks mounted, public
255
+ * ports, and an outbound network policy that can inject credentials at the
256
+ * boundary. It pauses when idle and resumes on the next connection,
257
+ * keeping its memory.
258
+ *
259
+ * Declare one as a box (a dev box or a preview environment that outlives
260
+ * deploys), or give it a `main` and it runs your program: Alchemy bundles
261
+ * it, uploads it, and registers it with Archil's service manager, which
262
+ * restarts it when it crashes and starts it whenever the sandbox boots. Its
263
+ * port gets a public `https://` URL, and a request to a stopped sandbox
264
+ * starts it again, typically in a few seconds. A code change uploads the
265
+ * program and restarts it in place.
266
+ *
267
+ * Short-lived sandboxes for one user or task are created at run time with
268
+ * {@link CreateSandbox}; act on a declared one with {@link ConnectSandbox}.
269
+ *
270
+ * :::caution
271
+ * Changing the image, size, timeouts, or the `env` of a sandbox without
272
+ * `main` replaces the sandbox, and a new one starts with an empty root
273
+ * filesystem. Keep data on a mounted disk. `mounts`, `ports`, `network`
274
+ * and the program update in place.
275
+ * :::
276
+ *
277
+ * :::caution
278
+ * An exposed port is public: anyone with the URL can call it. Check
279
+ * requests yourself, or hand clients an `Archil.SandboxPortToken`.
280
+ * :::
281
+ * @see https://docs.archil.com/compute/sandboxes/introduction
282
+ *
283
+ * ### Creating a Sandbox
284
+ * **Example:** A dev box with a disk mounted
285
+ * ```typescript
286
+ * export const Workspace = Archil.Disk("Workspace", {});
287
+ * export const DevBox = Archil.Sandbox("DevBox", {
288
+ * baseImage: "node:24-bookworm",
289
+ * vcpuCount: 2,
290
+ * memSizeMiB: 4096,
291
+ * mounts: [{ disk: Workspace, path: "/workspace" }],
292
+ * ports: [3000],
293
+ * idleTtlSeconds: 600,
294
+ * });
295
+ * ```
296
+ *
297
+ * **Example:** A bigger sandbox that never pauses while idle
298
+ * ```typescript
299
+ * export const Box = Archil.Sandbox("Box", {
300
+ * vcpuCount: 4,
301
+ * memSizeMiB: 8192,
302
+ * idleTtlSeconds: 0,
303
+ * });
304
+ * ```
305
+ *
306
+ * **Example:** Boot a private image
307
+ * ```typescript
308
+ * export const AgentImage = Archil.Image("AgentImage", {
309
+ * image: "ghcr.io/acme/agent:1",
310
+ * registry: { server: "ghcr.io", username: "acme", password: Config.Redacted("GHCR_TOKEN") },
311
+ * });
312
+ * export const Agent = Effect.gen(function* () {
313
+ * const image = yield* AgentImage;
314
+ * return yield* Archil.Sandbox("Agent", { image: image.imageId });
315
+ * });
316
+ * ```
317
+ *
318
+ * ### Setting Up at Deploy and Forking at Run Time
319
+ * **Example:** Clone a repository once, then fork the sandbox per task
320
+ * ```typescript
321
+ * export const Golden = Archil.Sandbox("Golden", {
322
+ * run: ["git clone --depth 1 https://github.com/sindresorhus/is /work/is"],
323
+ * });
324
+ *
325
+ * // in a Worker or program:
326
+ * const sandboxes = yield* Archil.CreateSandbox(Golden);
327
+ * const task = yield* sandboxes.create({ name: "task-1" });
328
+ * const result = yield* task.exec("git log -1", { cwd: "/work/is" });
329
+ * ```
330
+ *
331
+ * ### Running a Program
332
+ * **Example:** An Effect program that reads a mounted disk
333
+ * ```typescript
334
+ * // src/Api.ts
335
+ * import * as Archil from "@alchemy.run/archil";
336
+ * import * as Effect from "effect/Effect";
337
+ * import * as FileSystem from "effect/FileSystem";
338
+ * import * as HttpServerResponse from "effect/http/HttpServerResponse";
339
+ *
340
+ * export const Dataset = Archil.Disk("Dataset", {});
341
+ *
342
+ * export default class Api extends Archil.Sandbox<Api>()(
343
+ * "Api",
344
+ * { main: import.meta.url, port: 3000 },
345
+ * Effect.gen(function* () {
346
+ * const dataset = yield* Archil.MountDisk(Dataset, { path: "/data", mode: "read-only" });
347
+ * const fs = yield* FileSystem.FileSystem;
348
+ * return {
349
+ * fetch: Effect.gen(function* () {
350
+ * return yield* HttpServerResponse.json(yield* fs.readDirectory(dataset.path));
351
+ * }).pipe(Effect.orDie),
352
+ * };
353
+ * }).pipe(Effect.provide(Archil.MountDiskLive)),
354
+ * ) {}
355
+ * ```
356
+ *
357
+ * **Example:** A plain async handler with typed `env`
358
+ * ```typescript
359
+ * // src/infra.ts
360
+ * export const Hello = Archil.Sandbox("Hello", {
361
+ * main: "./src/hello.ts",
362
+ * env: { GREETING: "hello" },
363
+ * });
364
+ * export type HelloEnv = Archil.InferEnv<typeof Hello>;
365
+ * ```
366
+ *
367
+ * ```typescript
368
+ * // src/hello.ts
369
+ * import type { HelloEnv } from "./infra.ts";
370
+ *
371
+ * export default {
372
+ * async fetch(request: Request, env: HelloEnv) {
373
+ * return new Response(`${env.GREETING} from ${new URL(request.url).pathname}`);
374
+ * },
375
+ * };
376
+ * ```
377
+ *
378
+ * **Example:** Secrets in the program's environment
379
+ * ```typescript
380
+ * export const Billing = Archil.Sandbox("Billing", {
381
+ * main: "./src/billing.ts",
382
+ * env: { STRIPE_KEY: Config.Redacted("STRIPE_KEY") },
383
+ * });
384
+ * ```
385
+ *
386
+ * ### Mounting Disks
387
+ * Archil sandboxes mount disks natively. Each entry names the disk and a
388
+ * `path`, with Archil's own options: `mode` (`exclusive`, `conditional`
389
+ * or `read-only`), `subdirectory`, and `queueMs`.
390
+ *
391
+ * **Example:** Mount two disks, one read-only
392
+ * ```typescript
393
+ * export const Box = Archil.Sandbox("Box", {
394
+ * mounts: [
395
+ * { disk: Workspace, path: "/workspace" },
396
+ * { disk: Dataset, path: "/data", mode: "read-only" },
397
+ * ],
398
+ * });
399
+ * ```
400
+ *
401
+ * **Example:** Mount from the program with `MountDisk`
402
+ * ```typescript
403
+ * const uploads = yield* Archil.MountDisk(Uploads, { path: "/uploads", mode: "conditional" });
404
+ * // runtime: the disk is already mounted at uploads.path
405
+ * yield* fs.writeFileString(`${uploads.path}/${id}.txt`, body);
406
+ * ```
407
+ *
408
+ * ### Mount Clients From Other Bindings
409
+ * A binding that installs a filesystem client (an `alchemy/Mount`
410
+ * capability) into a program's image makes Alchemy build one: the
411
+ * sandbox's base plus the client's install lines, built with your local
412
+ * Docker and pushed to `push.repository` for Archil to pull. A different
413
+ * set of clients replaces the sandbox.
414
+ *
415
+ * **Example:** Give a program sandbox a repository for its derived image
416
+ * ```typescript
417
+ * export default class Indexer extends Archil.Sandbox<Indexer>()(
418
+ * "Indexer",
419
+ * {
420
+ * main: import.meta.url,
421
+ * push: {
422
+ * repository: "ghcr.io/acme/indexer",
423
+ * credentials: { server: "ghcr.io", username: "acme-bot", password: Alchemy.Secret("GHCR_TOKEN") },
424
+ * },
425
+ * },
426
+ * Effect.gen(function* () {
427
+ * const media = yield* Cloud.MountBucket(Media, { path: "/media" }); // installs a FUSE client
428
+ * return { fetch: Effect.succeed(HttpServerResponse.text(media.path)) };
429
+ * }),
430
+ * ) {}
431
+ * ```
432
+ *
433
+ * ### Packaging
434
+ * Alchemy bundles `main` with rolldown into one Node program. Packages
435
+ * that can't be bundled (native modules, or ones that read their own files
436
+ * at run time) go in `build.install` and are installed next to it with
437
+ * `npm install`.
438
+ *
439
+ * **Example:** Install a native package
440
+ * ```typescript
441
+ * export const Thumbs = Archil.Sandbox("Thumbs", {
442
+ * main: "./src/thumbs.ts",
443
+ * build: { install: ["sharp"] },
444
+ * });
445
+ * ```
446
+ *
447
+ * **Example:** Upload static files next to the program
448
+ * ```typescript
449
+ * export const Site = Archil.Sandbox("Site", {
450
+ * main: "./dist/server.mjs",
451
+ * extraFiles: [{ source: "./dist/client", destination: "client" }],
452
+ * });
453
+ * ```
454
+ *
455
+ * ### Ports
456
+ * **Example:** Serve a port and hand out a token for it
457
+ * ```typescript
458
+ * export const Preview = Archil.Sandbox("Preview", { ports: [8080] });
459
+ * export const PreviewToken = Effect.gen(function* () {
460
+ * const preview = yield* Preview;
461
+ * return yield* Archil.SandboxPortToken("PreviewToken", {
462
+ * sandbox: preview.sandboxId,
463
+ * port: 8080,
464
+ * ttl: "24h",
465
+ * });
466
+ * });
467
+ * ```
468
+ *
469
+ * ### Network Policy
470
+ * **Example:** Deny by default, inject a token for one API
471
+ * ```typescript
472
+ * export const Agent = Archil.Sandbox("Agent", {
473
+ * network: {
474
+ * egress: {
475
+ * default: "deny",
476
+ * allow: [
477
+ * "registry.npmjs.org",
478
+ * { target: "api.github.com", headers: { Authorization: Alchemy.Secret("GITHUB_AUTH") } },
479
+ * ],
480
+ * },
481
+ * },
482
+ * });
483
+ * ```
484
+ *
485
+ * **Example:** Forward an API's requests to a Worker gateway that adds the key
486
+ * ```typescript
487
+ * const gateway = yield* MyGateway;
488
+ * const sandbox = yield* Archil.Sandbox("MySandbox", {
489
+ * network: {
490
+ * egress: {
491
+ * default: "deny",
492
+ * allow: [
493
+ * {
494
+ * target: "api.example.com",
495
+ * // proves to the gateway that the request came through this policy
496
+ * headers: { "X-Gateway-Token": Config.Redacted("GATEWAY_TOKEN") },
497
+ * forwardUrl: gateway.url.as<string>(),
498
+ * },
499
+ * ],
500
+ * },
501
+ * },
502
+ * });
503
+ * ```
504
+ *
505
+ * ### Using a Sandbox at Run Time
506
+ * **Example:** Run a command and fork a preview from a Worker
507
+ * ```typescript
508
+ * const preview = yield* Archil.ConnectSandbox(Preview);
509
+ * const result = yield* preview.exec("pnpm build", { cwd: "/workspace" });
510
+ * const fork = yield* preview.fork({ name: "pr-42" });
511
+ * ```
512
+ *
513
+ * ### Looking up an existing sandbox
514
+ * `Sandbox.lookup` references a sandbox Alchemy doesn't manage (created in
515
+ * the Archil console or by another stack). It is read during plan/deploy,
516
+ * never created, updated or deleted.
517
+ * Its program fields are `undefined`: they come from the deploy that started
518
+ * the sandbox's program.
519
+ *
520
+ * **Example:** Reference an existing sandbox
521
+ * ```typescript
522
+ * const box = Archil.Sandbox.lookup({ name: "research" });
523
+ * ```
524
+ *
525
+ * @resource
526
+ * @product Sandbox
527
+ */
528
+ export const Sandbox: Platform<Sandbox, ServerHost, SandboxShape, HostRuntimeContext> &
529
+ LookupMethod<Sandbox> = Platform("Archil.Sandbox", {
530
+ createRuntimeContext: createHostRuntimeContext("Archil.Sandbox"),
531
+ // `{ disk: Workspace }` at module scope is an Effect: yield it here so
532
+ // the disk is registered and `news.mounts[i].disk` resolves to its
533
+ // attributes.
534
+ transformProps: (_id, props) =>
535
+ Effect.gen(function* () {
536
+ if (globalThis.__ALCHEMY_RUNTIME__ || props?.mounts === undefined) return props;
537
+ const mounts: DiskMount[] = [];
538
+ for (const mount of props.mounts as DiskMount[]) {
539
+ mounts.push(
540
+ Effect.isEffect(mount.disk)
541
+ ? { ...mount, disk: yield* mount.disk as Effect.Effect<Disk, never, Providers> }
542
+ : mount,
543
+ );
544
+ }
545
+ return { ...props, mounts };
546
+ }),
547
+ });
548
+
549
+ /**
550
+ * Map a sandbox and its exposed ports to attributes. Archil lists port
551
+ * hostnames separately; sandbox responses don't carry them. `program` is
552
+ * the port and code of the program the sandbox runs, if any.
553
+ */
554
+ export const toSandboxAttributes = (
555
+ sandbox: archil.Sandbox,
556
+ ports: ReadonlyArray<{ port: number; hostname: string }>,
557
+ program?: { readonly port: number; readonly code: { hash: string } },
558
+ derivedImage?: { readonly imageId: string; readonly hash: string },
559
+ ): SandboxAttributes => {
560
+ const endpoints = ports.map((e) => ({ port: e.port, hostname: e.hostname }));
561
+ const primary = (program && endpoints.find((e) => e.port === program.port)) ?? endpoints[0];
562
+ return {
563
+ sandboxId: sandbox.sandbox_id,
564
+ name: sandbox.name,
565
+ status: sandbox.status,
566
+ baseImage: sandbox.base_image,
567
+ imageDigest: sandbox.image_digest,
568
+ vcpuCount: sandbox.vcpu_count,
569
+ memSizeMiB: sandbox.mem_size_mib,
570
+ endpoints,
571
+ url: primary ? `https://${primary.hostname}` : undefined,
572
+ port: program?.port,
573
+ code: program?.code,
574
+ derivedImage: derivedImage && { imageId: derivedImage.imageId, hash: derivedImage.hash },
575
+ setupHash: undefined,
576
+ };
577
+ };
578
+
579
+ /** Read a sandbox's exposed ports and map it to attributes. */
580
+ export const observeSandbox = Effect.fn(function* (
581
+ sandbox: archil.Sandbox,
582
+ program?: { readonly port: number; readonly code: { hash: string } },
583
+ derivedImage?: { readonly imageId: string; readonly hash: string },
584
+ ) {
585
+ const ports = yield* archil.listSandboxPorts({ sid: sandbox.sandbox_id }).pipe(
586
+ Effect.map(({ data }) => data.ports ?? []),
587
+ Effect.catchTag("NotFound", () => Effect.succeed([])),
588
+ );
589
+ return toSandboxAttributes(sandbox, ports, program, derivedImage);
590
+ });
591
+
592
+ /** The create request for sandbox props, with mounts already on the wire. */
593
+ export const toCreateSandboxRequest = (
594
+ name: string | undefined,
595
+ props: Omit<SandboxProps, "name" | "mounts">,
596
+ mounts: ReadonlyArray<archil.SandboxMount> | undefined,
597
+ ): archil.CreateSandboxRequest => ({
598
+ wait: true,
599
+ name,
600
+ image_id: props.image,
601
+ base_image: props.image ? undefined : props.baseImage,
602
+ vcpu_count: props.vcpuCount,
603
+ mem_size_mib: props.memSizeMiB,
604
+ ports: props.ports,
605
+ enable_service_ingress: props.enableServiceIngress,
606
+ env: toEnv(props.env),
607
+ network: props.network?.egress ? { egress: toEgressPolicy(props.network.egress) } : undefined,
608
+ mounts: mounts && mounts.length > 0 ? [...mounts] : undefined,
609
+ max_ttl_seconds: props.maxTtlSeconds,
610
+ idle_ttl_seconds: props.idleTtlSeconds,
611
+ max_concurrent_execs: props.maxConcurrentExecs,
612
+ });
613
+
614
+ /** Stop a live sandbox and wait until it has stopped. */
615
+ const stopSandbox = Effect.fn(function* (sid: string) {
616
+ const observed = yield* getSandbox(sid);
617
+ if (observed === undefined || deletableSandboxStates.has(observed.status)) return;
618
+ // Let a booting or pausing sandbox settle, then stop it.
619
+ yield* waitForSandbox(sid).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
620
+ yield* archil.stopSandbox({ sid }).pipe(
621
+ Effect.asVoid,
622
+ Effect.catchTag("NotFound", () => Effect.void),
623
+ );
624
+ yield* waitForSandboxStopped(sid);
625
+ });
626
+
627
+ /**
628
+ * Delete a sandbox and wait until it is gone. Archil only deletes stopped,
629
+ * exited or failed sandboxes, so a live one is stopped first. Deleting a
630
+ * missing sandbox succeeds.
631
+ */
632
+ export const deleteSandbox = Effect.fn(function* (sid: string) {
633
+ const observed = yield* getSandbox(sid);
634
+ if (observed === undefined) return;
635
+ yield* stopSandbox(sid);
636
+ yield* archil.deleteSandbox({ sid }).pipe(
637
+ Effect.asVoid,
638
+ Effect.catchTag("NotFound", () => Effect.void),
639
+ );
640
+ yield* waitForSandboxGone(sid);
641
+ });
642
+
643
+ /**
644
+ * Wire mounts for a list of entries whose disks resolved to attributes or
645
+ * handles. Fails when a disk has no id yet.
646
+ */
647
+ export const toSandboxMounts = Effect.fn(function* (
648
+ owner: string,
649
+ mounts: ReadonlyArray<DiskMount>,
650
+ ) {
651
+ const wire: archil.SandboxMount[] = [];
652
+ for (const mount of mounts) {
653
+ const diskId = mountDiskId(mount.disk);
654
+ if (diskId === undefined) {
655
+ return yield* new MountDiskUnresolved({
656
+ path: mount.path,
657
+ message: `${owner}: the disk mounted at ${mount.path} has no diskId. Pass an Archil.Disk or a DiskHandle.`,
658
+ });
659
+ }
660
+ wire.push(yield* toSandboxMount(diskId, mount));
661
+ }
662
+ return wire;
663
+ });
664
+
665
+ const activeBindings = (bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>) =>
666
+ bindings.filter(
667
+ (b: ResourceBinding<SandboxBinding> & { action?: string }) => b.action !== "delete",
668
+ );
669
+
670
+ /** The sandbox's mounts: the props' entries, then the ones bindings add. */
671
+ const desiredMounts = Effect.fn(function* (
672
+ id: string,
673
+ mounts: ReadonlyArray<DiskMount> | undefined,
674
+ bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>,
675
+ ) {
676
+ const wire = yield* toSandboxMounts(`Archil.Sandbox ${id}`, mounts ?? []);
677
+ for (const binding of activeBindings(bindings)) {
678
+ for (const mount of binding.data?.mounts ?? []) {
679
+ if (!isCapability(mount)) wire.push(yield* toSandboxMount(mount.diskId, mount));
680
+ }
681
+ }
682
+ // A binding and the props can name the same mount; keep one.
683
+ const seen = new Set<string>();
684
+ return wire.filter((mount) => {
685
+ const key = JSON.stringify(mountIdentity(mount));
686
+ if (seen.has(key)) return false;
687
+ seen.add(key);
688
+ return true;
689
+ });
690
+ });
691
+
692
+ const isCapability = (mount: SandboxMountBinding | MountCapability): mount is MountCapability =>
693
+ "install" in mount;
694
+
695
+ /** The filesystem clients bindings install into the sandbox's image. */
696
+ const boundCapabilities = (bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>) =>
697
+ collectMountCapabilities(
698
+ activeBindings(bindings).map((b) => ({
699
+ data: { mounts: (b.data?.mounts ?? []).filter(isCapability) },
700
+ })),
701
+ );
702
+
703
+ /** The reference a derived image builds on: the `image`'s source, or `baseImage`. */
704
+ const baseReference = Effect.fn(function* (props: SandboxProps) {
705
+ if (props.image === undefined) return props.baseImage ?? "node:24-bookworm";
706
+ const { data } = yield* archil.getImage({ image_id: props.image });
707
+ return data.canonical_source ?? data.source;
708
+ });
709
+
710
+ /**
711
+ * The plan for a sandbox's derived image: its base plus the install lines
712
+ * of the capabilities bound onto it. `undefined` when nothing is bound, so
713
+ * the sandbox boots its base directly. Archil sandboxes run as root with
714
+ * `/dev/fuse` and NFS, so every requirement is met.
715
+ */
716
+ const planDerivedImage = Effect.fn(function* (
717
+ id: string,
718
+ props: SandboxProps,
719
+ capabilities: ReadonlyArray<MountCapability>,
720
+ ) {
721
+ if (capabilities.length === 0) return undefined;
722
+ const unsupported = checkMountSupport(`Archil.Sandbox ${id}`, capabilities, {
723
+ fuse: true,
724
+ nfs: true,
725
+ });
726
+ if (unsupported) return yield* unsupported;
727
+ return yield* BuildPlan.plan(
728
+ { image: yield* baseReference(props) },
729
+ { capabilities: imageCapabilities, install: mountInstallLines(capabilities) },
730
+ );
731
+ });
732
+
733
+ /** Build, push and pull a derived image, or keep the one `output` already has. */
734
+ const ensureDerivedImage = Effect.fn(function* (
735
+ id: string,
736
+ plan: BuildPlan.BuildPlan,
737
+ push: ImagePush | undefined,
738
+ existing: { readonly imageId: string; readonly hash: string } | undefined,
739
+ ) {
740
+ if (existing?.hash === plan.hash) return existing;
741
+ if (push === undefined) {
742
+ return yield* new ImageRequiresBuild({
743
+ message: `Archil.Sandbox ${id}: a binding installs a filesystem client into its image, so Alchemy has to build one. Set \`push: { repository, credentials }\` for Archil to pull it from.`,
744
+ });
745
+ }
746
+ const published = yield* publishPlan(plan, push);
747
+ const image = yield* pullImage(published.source, published.credentials, plan.hash);
748
+ return { imageId: image.imageId, hash: plan.hash };
749
+ });
750
+
751
+ const sortedMounts = (mounts: ReadonlyArray<archil.SandboxMount>) =>
752
+ mounts.map(mountIdentity).sort((a, b) => a.path.localeCompare(b.path));
753
+
754
+ /**
755
+ * Converge a sandbox's mounts. Archil only changes mounts when a stopped
756
+ * sandbox starts, so a change stops it and starts it with the new ones;
757
+ * its root filesystem is kept.
758
+ */
759
+ const syncMounts = Effect.fn(function* (
760
+ sandbox: archil.Sandbox,
761
+ desired: ReadonlyArray<archil.SandboxMount>,
762
+ ) {
763
+ if (deepEqual(sortedMounts(sandbox.mounts ?? []), sortedMounts(desired))) return false;
764
+ const sid = sandbox.sandbox_id;
765
+ yield* stopSandbox(sid);
766
+ yield* archil.startSandbox({ sid, wait: true, mounts: [...desired] });
767
+ return true;
768
+ });
769
+
770
+ /** Converge a sandbox's exposed ports. */
771
+ const syncPorts = Effect.fn(function* (sid: string, desired: number[] | undefined) {
772
+ if (desired === undefined) return;
773
+ const { data } = yield* archil.listSandboxPorts({ sid });
774
+ const observed = new Set((data.ports ?? []).map((p) => p.port));
775
+ const want = new Set(desired);
776
+ for (const port of want) {
777
+ if (!observed.has(port)) yield* archil.exposeSandboxPort({ sid, port });
778
+ }
779
+ for (const port of observed) {
780
+ if (!want.has(port)) yield* archil.deleteSandboxPort({ sid, port });
781
+ }
782
+ });
783
+
784
+ /** Converge a sandbox's egress policy. */
785
+ const syncNetwork = Effect.fn(function* (sid: string, egress: SandboxEgress | undefined) {
786
+ if (egress === undefined) return;
787
+ const desired = toEgressPolicy(egress);
788
+ const { data } = yield* archil.getSandboxNetwork({ sid });
789
+ const { managed, policy } = splitArchilEgress(data.egress);
790
+ if (!deepEqual(policy, desired)) {
791
+ yield* archil.updateSandboxNetwork({
792
+ sid,
793
+ egress: { ...desired, allow: [...(desired.allow ?? []), ...managed] },
794
+ });
795
+ }
796
+ });
797
+
798
+ /** Props that can't change on a running sandbox. */
799
+ const createOnly = (props: SandboxProps | undefined) => ({
800
+ image: props?.image,
801
+ baseImage: props?.baseImage,
802
+ vcpuCount: props?.vcpuCount,
803
+ memSizeMiB: props?.memSizeMiB,
804
+ enableServiceIngress: props?.enableServiceIngress,
805
+ // A program's environment is the program's: it restarts, the box stays.
806
+ env: props?.main === undefined ? toEnv(props?.env) : undefined,
807
+ maxTtlSeconds: props?.maxTtlSeconds,
808
+ maxConcurrentExecs: props?.maxConcurrentExecs,
809
+ run: props?.run ?? [],
810
+ });
811
+
812
+ /** Exclusive mounts in props whose disks are known: one sandbox at a time holds each. */
813
+ const exclusiveDisks = (props: SandboxProps | undefined) =>
814
+ new Set(
815
+ (props?.mounts ?? [])
816
+ .filter((m) => (m.mode ?? "exclusive") === "exclusive")
817
+ .map((m) => mountDiskId(m.disk))
818
+ .filter((id) => id !== undefined),
819
+ );
820
+
821
+ const DEFAULT_PORT = 3000;
822
+
823
+ /** The name of the program's service inside its sandbox. */
824
+ const PROGRAM = "program";
825
+
826
+ /** The public hostname of `port`, exposing it when it isn't yet. */
827
+ const exposePort = Effect.fn(function* (sid: string, port: number) {
828
+ const { data } = yield* archil.listSandboxPorts({ sid });
829
+ const existing = (data.ports ?? []).find((p) => p.port === port);
830
+ if (existing) return existing.hostname;
831
+ const { data: exposed } = yield* archil.exposeSandboxPort({ sid, port });
832
+ return exposed.hostname;
833
+ });
834
+
835
+ export const SandboxProvider = () =>
836
+ Provider.effect(
837
+ Sandbox,
838
+ Effect.gen(function* () {
839
+ const stack = yield* Stack;
840
+ const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
841
+ const alchemyEnv = {
842
+ ALCHEMY_STACK_NAME: stack.name,
843
+ ALCHEMY_STAGE: stack.stage,
844
+ ALCHEMY_PHASE: "runtime",
845
+ HOST: "0.0.0.0",
846
+ };
847
+ return Sandbox.Provider.of({
848
+ stables: ["sandboxId", "name"],
849
+ nuke: { dependsOn: ["Archil.Disk"] },
850
+ list: Effect.fn(function* () {
851
+ return (yield* listSandboxes())
852
+ .filter(
853
+ (s) =>
854
+ (isAlchemyName(s.name) || isRuntimeObjectName(s.name)) && s.status !== "deleted",
855
+ )
856
+ .map((sandbox) => toSandboxAttributes(sandbox, []));
857
+ }),
858
+ diff: Effect.fn(function* ({ id, olds, news, output, newBindings }) {
859
+ if (!isResolved(news)) {
860
+ // A program class's props hold its implementation (`exports`),
861
+ // unresolved at plan. The bundle depends only on `main`,
862
+ // `handler` and `build`, which are plain, so a code change still
863
+ // redeploys the program in place.
864
+ const program = news as SandboxProps;
865
+ if (typeof program.main === "string" && output !== undefined) {
866
+ const { hash } = yield* bundleProgram(
867
+ program as SandboxProps & { main: string },
868
+ virtualEntryPlugin,
869
+ );
870
+ if (hash !== output.code?.hash) return { action: "update" } as const;
871
+ }
872
+ return undefined;
873
+ }
874
+ const name = output?.name ?? (yield* sandboxName(id, olds?.name));
875
+ if (news.name !== undefined && news.name !== name) {
876
+ return { action: "replace" } as const;
877
+ }
878
+ if (olds !== undefined && !deepEqual(createOnly(news), createOnly(olds))) {
879
+ // A disk mounted for exclusive writes can only be held by one
880
+ // sandbox, so the old one has to let go before the new one boots.
881
+ const held = exclusiveDisks(olds);
882
+ const deleteFirst = [...exclusiveDisks(news)].some((d) => held.has(d));
883
+ return { action: "replace", deleteFirst } as const;
884
+ }
885
+ // A new set of installed clients builds a new image: a new sandbox.
886
+ if (output !== undefined && isResolved(newBindings)) {
887
+ const plan = yield* planDerivedImage(
888
+ id,
889
+ news,
890
+ boundCapabilities(newBindings as ReadonlyArray<ResourceBinding<SandboxBinding>>),
891
+ );
892
+ if (plan?.hash !== output.derivedImage?.hash) {
893
+ const held = exclusiveDisks(olds);
894
+ const deleteFirst = [...exclusiveDisks(news)].some((d) => held.has(d));
895
+ return { action: "replace", deleteFirst } as const;
896
+ }
897
+ }
898
+ if (news.main !== undefined) {
899
+ const { hash } = yield* bundleProgram(
900
+ news as SandboxProps & { main: string },
901
+ virtualEntryPlugin,
902
+ );
903
+ if (hash !== output?.code?.hash || (news.port ?? DEFAULT_PORT) !== output?.port) {
904
+ return { action: "update" } as const;
905
+ }
906
+ } else if (output?.code !== undefined) {
907
+ return { action: "update" } as const;
908
+ }
909
+ if (
910
+ !deepEqual(egressIdentity(news.network?.egress), egressIdentity(olds?.network?.egress))
911
+ ) {
912
+ return { action: "update" } as const;
913
+ }
914
+ return undefined;
915
+ }),
916
+ lookup: Effect.fn(function* ({ name }) {
917
+ const sandbox = yield* findSandboxByName(name);
918
+ return sandbox ? yield* observeSandbox(sandbox) : undefined;
919
+ }),
920
+
921
+ read: Effect.fn(function* ({ id, olds, output }) {
922
+ const name = output?.name ?? (yield* sandboxName(id, olds?.name));
923
+ const sandbox =
924
+ (output ? yield* getSandbox(output.sandboxId) : undefined) ??
925
+ (yield* findSandboxByName(name));
926
+ if (!sandbox) return undefined;
927
+ const program =
928
+ output?.port !== undefined && output.code !== undefined
929
+ ? { port: output.port, code: output.code }
930
+ : undefined;
931
+ const attrs = {
932
+ ...(yield* observeSandbox(sandbox, program, output?.derivedImage)),
933
+ setupHash: output?.setupHash,
934
+ };
935
+ if (output === undefined && olds?.name !== undefined && !isAlchemyName(sandbox.name)) {
936
+ return Unowned(attrs);
937
+ }
938
+ return attrs;
939
+ }),
940
+ reconcile: Effect.fn(function* ({ id, news, output, bindings, session }) {
941
+ const name = output?.name ?? (yield* sandboxName(id, news.name));
942
+ const mounts = yield* desiredMounts(id, news.mounts, bindings);
943
+ const capabilities = boundCapabilities(bindings);
944
+ const hosted = news.main !== undefined;
945
+ // Observe
946
+ let sandbox =
947
+ (output ? yield* getSandbox(output.sandboxId) : undefined) ??
948
+ (yield* findSandboxByName(name));
949
+ // Ensure
950
+ const derivedPlan = yield* planDerivedImage(id, news, capabilities);
951
+ let derivedImage = output?.derivedImage;
952
+ if (derivedPlan !== undefined) {
953
+ if (derivedPlan.hash !== derivedImage?.hash) {
954
+ yield* session.note(`Building the image for sandbox ${name}...`);
955
+ }
956
+ derivedImage = yield* ensureDerivedImage(id, derivedPlan, news.push, derivedImage);
957
+ } else {
958
+ derivedImage = undefined;
959
+ }
960
+ if (!sandbox) {
961
+ yield* session.note(`Creating sandbox ${name}...`);
962
+ const request = toCreateSandboxRequest(
963
+ name,
964
+ {
965
+ ...news,
966
+ image: derivedImage?.imageId ?? news.image,
967
+ env: hosted ? undefined : news.env,
968
+ },
969
+ mounts,
970
+ );
971
+ sandbox = yield* archil.createSandbox(request).pipe(
972
+ Effect.map(({ data }) => data),
973
+ Effect.catchTag("SandboxNameConflict", (conflict) =>
974
+ findSandboxByName(name).pipe(
975
+ Effect.flatMap((found) =>
976
+ found ? Effect.succeed(found) : Effect.fail(conflict),
977
+ ),
978
+ ),
979
+ ),
980
+ );
981
+ }
982
+ const sid = sandbox.sandbox_id;
983
+ sandbox = yield* waitForSandbox(sid);
984
+ // Sync
985
+ if (yield* syncMounts(sandbox, mounts)) {
986
+ yield* session.note(`Remounted disks on sandbox ${name}`);
987
+ sandbox = yield* waitForSandbox(sid);
988
+ }
989
+ yield* syncNetwork(sid, news.network?.egress);
990
+ const port = news.port ?? DEFAULT_PORT;
991
+ yield* syncPorts(
992
+ sid,
993
+ news.ports === undefined ? undefined : hosted ? [...news.ports, port] : news.ports,
994
+ );
995
+ if (
996
+ news.idleTtlSeconds !== undefined &&
997
+ news.idleTtlSeconds !== sandbox.idle_ttl_seconds
998
+ ) {
999
+ yield* archil.setSandboxTimeout({ sid, idle_ttl_seconds: news.idleTtlSeconds });
1000
+ }
1001
+ // Setup: run once, on the sandbox as created. A change replaces the
1002
+ // sandbox (see `createOnly`), so the commands always start clean.
1003
+ const run = news.run ?? [];
1004
+ const setupHash = run.length > 0 ? yield* sha256Object(run) : undefined;
1005
+ if (setupHash !== undefined && setupHash !== output?.setupHash) {
1006
+ for (const command of run) {
1007
+ yield* session.note(`Running \`${command}\` in sandbox ${name}...`);
1008
+ const result = yield* Process.exec(sid, command);
1009
+ if (result.exitCode !== 0) {
1010
+ return yield* new SandboxSetupFailed({
1011
+ sandbox: name,
1012
+ command,
1013
+ exitCode: result.exitCode ?? -1,
1014
+ stderr: result.stderr,
1015
+ });
1016
+ }
1017
+ }
1018
+ // Flush to disk, so a fork or a cold start sees what the commands wrote.
1019
+ yield* Process.exec(sid, "sync");
1020
+ }
1021
+ let program: { port: number; code: { hash: string } } | undefined;
1022
+ if (hosted) {
1023
+ const bindingEnv = activeBindings(bindings).reduce<Record<string, unknown>>(
1024
+ (acc, b) => ({ ...acc, ...b.data?.env }),
1025
+ {},
1026
+ );
1027
+ const bundled = yield* bundleProgram(
1028
+ news as SandboxProps & { main: string },
1029
+ virtualEntryPlugin,
1030
+ );
1031
+ // A moved port leaves the old one exposed; take it down.
1032
+ if (output?.port !== undefined && output.port !== port) {
1033
+ yield* archil
1034
+ .deleteSandboxPort({ sid, port: output.port })
1035
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
1036
+ }
1037
+ yield* deployProgram({
1038
+ sid,
1039
+ name: PROGRAM,
1040
+ port,
1041
+ program: bundled,
1042
+ env: { ...mountEnv(capabilities), ...bindingEnv, ...alchemyEnv, ...news.env },
1043
+ });
1044
+ yield* exposePort(sid, port);
1045
+ program = { port, code: { hash: bundled.hash } };
1046
+ } else if (output?.code !== undefined) {
1047
+ // `main` was removed: the box stays, the program goes.
1048
+ yield* removeProgram(sid, PROGRAM);
1049
+ if (output.port !== undefined && !(news.ports ?? []).includes(output.port)) {
1050
+ yield* archil
1051
+ .deleteSandboxPort({ sid, port: output.port })
1052
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
1053
+ }
1054
+ }
1055
+ // Return
1056
+ const final = yield* getSandbox(sid);
1057
+ return { ...(yield* observeSandbox(final ?? sandbox, program, derivedImage)), setupHash };
1058
+ }),
1059
+ delete: Effect.fn(function* ({ output }) {
1060
+ yield* deleteSandbox(output.sandboxId);
1061
+ }),
1062
+ });
1063
+ }),
1064
+ );