1import { waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type Sandbox as RailwaySandbox,
6 type SandboxCheckpoint as RailwaySandboxCheckpoint,
7 type SandboxNetworkIsolation,
8 type SandboxStatus,
9 type SandboxCreateInput,
10 type SandboxTemplateInput,
11} from "@distilled.cloud/railway";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Schedule from "effect/Schedule";
16import * as Stream from "effect/Stream";
17import * as Binding from "../Binding.ts";
18import { isResolved } from "../Diff.ts";
19import * as Provider from "../Provider.ts";
20import { Resource } from "../Resource.ts";
21import type { RuntimeContext } from "../RuntimeContext.ts";
22import { ownedProjects, projectEnvironmentIds } from "./Project.ts";
23import type { Providers } from "./Providers.ts";
24
25const sandboxFields = <E>(sandbox: Query<RailwaySandbox, E>) => ({
26 id: sandbox.id,
27 environmentId: sandbox.environmentId,
28 region: sandbox.region,
29 status: sandbox.status,
30 idleTimeoutMinutes: sandbox.idleTimeoutMinutes,
31 networkIsolation: sandbox.networkIsolation,
32 createdAt: sandbox.createdAt,
33 domains: sandbox.domains.pipe(
34 Query.map((domain) => ({
35 prefix: domain.prefix,
36 port: domain.port,
37 domain: domain.domain,
38 })),
39 ),
40});
41type CloudSandbox = UnwrapPlan<ReturnType<typeof sandboxFields>>;
42
43const checkpointFields = <E>(
44 checkpoint: Query<RailwaySandboxCheckpoint, E>,
45) => ({
46 createdAt: checkpoint.createdAt,
47 environmentId: checkpoint.environmentId,
48 id: checkpoint.id,
49 key: checkpoint.key,
50});
51type SandboxCheckpointsResultItem = UnwrapPlan<
52 ReturnType<typeof checkpointFields>
53>;
54
55/**
56 * A resource-valued prop: the resource itself, or an Effect that produces
57 * it (so `yield* Project(...)` and `Project(...)` both type-check).
58 */
59type Ref<T> = T | Effect.Effect<T, never, Providers>;
60
61/**
62 * Environment identity a Sandbox is created in. Accepts a
63 * `Railway.Project` (its primary environment), a `Railway.Environment`,
64 * or an `{ environmentId }` stub.
65 */
66export type SandboxEnvironment = {
67 readonly environmentId: string;
68 readonly projectId?: string;
69};
70
71/**
72 * Sandbox identity for {@link Exec} / helpers. Accepts a
73 * `Railway.Sandbox` or a `{ sandboxId, environmentId }` stub.
74 */
75export type SandboxIdentity = {
76 readonly sandboxId: string;
77 readonly environmentId: string;
78};
79
80/**
81 * Create-time template for a sandbox. Mutually exclusive `instructions`
82 * (build a recipe) and `name` (boot from a named checkpoint).
83 */
84export interface SandboxTemplate {
85 /** Region in which to build the template. Checkpoints retain their captured region. */
86 region?: string;
87 /**
88 * Build a template by running these shell instructions on the base
89 * image. Mutually exclusive with `name`.
90 */
91 instructions?: readonly string[];
92 /**
93 * Boot from a saved checkpoint with this name. Mutually exclusive
94 * with `instructions`.
95 */
96 name?: string;
97 /**
98 * Environment variables available to the template's build
99 * instructions. Values may contain Railway variable references.
100 */
101 variables?: Record<string, string>;
102}
103
104export interface SandboxDomain {
105 /** Port of the HTTP server listening on `0.0.0.0`, from 1 to 65535. */
106 port: number;
107 /** Unique domain prefix. Railway generates one when omitted. */
108 prefix?: string;
109}
110
111export interface SandboxResources {
112 /** Maximum vCPU allocation. Fractional values are supported. */
113 cpu?: number;
114 /** Maximum memory allocation in GB. Subject to the workspace plan limit. */
115 memoryGB?: number;
116}
117
118export interface SandboxProps {
119 /**
120 * Environment to create the sandbox in. Accepts a `Railway.Project`
121 * (primary environment), a `Railway.Environment`, or
122 * `{ environmentId }`. Changing it replaces the Sandbox.
123 */
124 environment: Ref<SandboxEnvironment>;
125 /**
126 * Region to place the sandbox in (`us-west2`, `us-east4-eqdc4a`, …).
127 * Defaults to US West when omitted. Changing it replaces the Sandbox.
128 */
129 region?: string;
130 /**
131 * Minutes of idle time before Railway auto-destroys the sandbox.
132 * Plan-dependent default and maximum. Changing it replaces the
133 * Sandbox — there is no update API.
134 */
135 idleTimeoutMinutes?: number;
136 /**
137 * Network access. `ISOLATED` (default) has outbound internet only.
138 * `PRIVATE` also joins the environment's private network. Changing
139 * it replaces the Sandbox.
140 */
141 networkIsolation?: SandboxNetworkIsolation;
142 /**
143 * Public HTTPS routes, up to ten unique ports. Requires `networkIsolation:
144 * "PRIVATE"`. Changing or removing routes replaces the sandbox.
145 */
146 publicDomains?: SandboxDomain[];
147 /**
148 * VM allocation, bounded by the workspace plan. Omit for the plan default.
149 * Changing or removing it replaces the sandbox.
150 */
151 resources?: SandboxResources;
152 /**
153 * Running sandbox whose disk to fork. The source must be in this environment;
154 * the fork inherits its region, but not variables, routes or idle timeout.
155 * Mutually exclusive with `template`. Changing it replaces the sandbox.
156 */
157 sourceSandboxId?: string;
158 /**
159 * Template to boot from: build instructions, or a named checkpoint.
160 * Changing it replaces the Sandbox.
161 */
162 template?: SandboxTemplate;
163 /**
164 * Environment variables baked into the sandbox, available to every
165 * command. Values may contain Railway variable references, resolved
166 * at create time. Changing them replaces the Sandbox.
167 */
168 variables?: Record<string, string>;
169}
170
171export type Sandbox = Resource<
172 "Railway.Sandbox",
173 SandboxProps,
174 {
175 /** Railway sandbox id. */
176 sandboxId: string;
177 /** Environment the sandbox lives in. */
178 environmentId: string;
179 /** Parent Railway project id, if known. */
180 projectId: string | undefined;
181 /** Region the sandbox was placed in. */
182 region: string;
183 /** Observed status (`CREATING`, `RUNNING`, `FAILED`, …). */
184 status: SandboxStatus;
185 /** Idle timeout in minutes, or `undefined` when Railway omitted it. */
186 idleTimeoutMinutes: number | undefined;
187 /** Network isolation mode. */
188 networkIsolation: SandboxNetworkIsolation;
189 /** RFC3339 creation timestamp. */
190 createdAt: string;
191 /** Published HTTPS routes. Empty when no public domains were requested. */
192 domains: {
193 /** Domain prefix assigned by Railway. */
194 prefix: string;
195 /** HTTP server port. */
196 port: number;
197 /** Hostname served over HTTPS. */
198 domain: string;
199 }[];
200 },
201 never,
202 Providers
203>;
204
205const resolveSandboxProps = (
206 props: SandboxProps | Effect.Effect<SandboxProps, never, Providers>,
207): Effect.Effect<SandboxProps, never, Providers> =>
208 Effect.gen(function* () {
209 const resolved = Effect.isEffect(props) ? yield* props : props;
210 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
211 const environment = Effect.isEffect(resolved.environment)
212 ? yield* resolved.environment as Effect.Effect<
213 SandboxEnvironment,
214 never,
215 Providers
216 >
217 : resolved.environment;
218 return { ...resolved, environment };
219 });
220
221const SandboxResource = Resource<Sandbox>("Railway.Sandbox");
222
223/**
224 * A Railway.Sandbox is an ephemeral Linux VM in an environment. Create
225 * it, {@link execSandbox} commands, snapshot with checkpoints, and
226 * destroy it when the task is done. Sandboxes are available on every plan.
227 *
228 * Railway has no labels and sandboxes have no names. Identity is the
229 * Railway sandbox id. There is no in-place update — changing
230 * `environment`, `region`, `idleTimeoutMinutes`, `networkIsolation`,
231 * `template`, `variables`, `publicDomains`, `resources`, or `sourceSandboxId`
232 * replaces the Sandbox. Removing a previously configured option also replaces it.
233 *
234 * @see https://docs.railway.com/sandboxes
235 * @see https://docs.railway.com/guides/code-execution-sandboxes
236 *
237 * ### Create a Sandbox
238 * Pass a Project (or Environment). Alchemy waits until the sandbox is
239 * `RUNNING` and ready to exec.
240 *
241 * **Example:** From a Project
242 * ```typescript
243 * const site = yield* Railway.Project("Site");
244 * const box = yield* Railway.Sandbox("Box", {
245 * environment: site,
246 * });
247 * ```
248 *
249 * :::caution[Changing `environment` or `region` replaces the Sandbox]
250 * A new VM is created. The old sandbox is destroyed.
251 * :::
252 *
253 * ### Idle timeout
254 * Railway auto-destroys a sandbox after it sits idle. Active exec sessions
255 * and SSH foreground work defer teardown; background processes alone do not.
256 * Hobby/Pro default is 30 minutes (max 120), with `0` disabling idle teardown.
257 * Trial/Free default and max is 5 minutes. A sandbox with idle teardown disabled
258 * remains billable until explicitly destroyed.
259 *
260 * **Example:** Short idle timeout
261 * ```typescript
262 * const box = yield* Railway.Sandbox("Box", {
263 * environment: site,
264 * idleTimeoutMinutes: 5,
265 * });
266 * ```
267 *
268 * :::caution[Changing `idleTimeoutMinutes` replaces the Sandbox]
269 * There is no sandbox update API.
270 * :::
271 *
272 * ### Variables
273 * Baked into the sandbox at create time. Available to every command.
274 *
275 * **Example:** Create-time env
276 * ```typescript
277 * const box = yield* Railway.Sandbox("Box", {
278 * environment: site,
279 * variables: { NODE_ENV: "production" },
280 * });
281 * ```
282 *
283 * ### Template
284 * Boot from a named checkpoint, or from build instructions.
285 *
286 * **Example:** Checkpoint
287 * ```typescript
288 * const box = yield* Railway.Sandbox("Box", {
289 * environment: site,
290 * template: { name: "after-deps" },
291 * });
292 * ```
293 *
294 * ### Publish HTTP ports
295 * **Example:** Private-network sandbox with an HTTPS route
296 * ```typescript
297 * const preview = yield* Railway.Sandbox("Preview", {
298 * environment: site,
299 * networkIsolation: "PRIVATE",
300 * publicDomains: [{ port: 3000 }],
301 * resources: { cpu: 1, memoryGB: 1 },
302 * });
303 * ```
304 * Start the HTTP server on `0.0.0.0:3000`. Published hostnames are available
305 * in `preview.domains`; creating a route does not start the server.
306 *
307 * ### Fork a sandbox
308 * **Example:** Independent copy of a running sandbox's disk
309 * ```typescript
310 * const fork = yield* Railway.Sandbox("Attempt", {
311 * environment: site,
312 * sourceSandboxId: box.sandboxId,
313 * idleTimeoutMinutes: 5,
314 * });
315 * ```
316 * Files are copied, but running processes and memory are not. Keep the source
317 * declared while replacing a fork. For a reusable named disk snapshot, use
318 * `Railway.SandboxCheckpoint` and pass its `name` as `template.name`.
319 *
320 * ### Exec
321 * Run a command after deploy with {@link execSandbox} or {@link Exec}.
322 *
323 * **Example:** Echo
324 * ```typescript
325 * const result = yield* Railway.execSandbox({
326 * sandboxId: box.sandboxId,
327 * environmentId: box.environmentId,
328 * command: "echo hello",
329 * });
330 * ```
331 *
332 * ### Module-scope declarations
333 * Resource-valued props accept the resource or an Effect producing it.
334 *
335 * **Example:** Module-scope Sandbox
336 * ```typescript
337 * // src/box.ts
338 * import * as Railway from "alchemy/Railway";
339 *
340 * export const Site = Railway.Project("Site");
341 * export const Box = Railway.Sandbox("Box", {
342 * environment: Site,
343 * idleTimeoutMinutes: 10,
344 * });
345 * ```
346 *
347 * @resource
348 * @product Sandbox
349 */
350export const Sandbox: typeof SandboxResource = Object.assign(
351 (
352 id: string,
353 props: SandboxProps | Effect.Effect<SandboxProps, never, Providers>,
354 ) => SandboxResource(id, resolveSandboxProps(props)),
355 SandboxResource,
356);
357
358export class SandboxNotCreated extends Data.TaggedError(
359 "Railway.SandboxNotCreated",
360)<{
361 environmentId: string;
362}> {}
363
364export class SandboxEnvironmentRequired extends Data.TaggedError(
365 "Railway.SandboxEnvironmentRequired",
366)<{
367 message: string;
368}> {}
369
370export class SandboxFailed extends Data.TaggedError("Railway.SandboxFailed")<{
371 sandboxId: string;
372 status: string;
373}> {}
374
375export class SandboxCheckpointNotFound extends Data.TaggedError(
376 "Railway.SandboxCheckpointNotFound",
377)<{
378 environmentId: string;
379 name: string;
380}> {}
381
382class SandboxPending extends Data.TaggedError("Railway.SandboxPending")<{
383 sandboxId: string;
384 status: string;
385}> {}
386
387const environmentIdOf = (value: unknown): string | undefined => {
388 if (value === null || typeof value !== "object") return undefined;
389 const rec = value as { environmentId?: unknown };
390 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
391 ? rec.environmentId
392 : undefined;
393};
394
395const projectIdOf = (value: unknown): string | undefined => {
396 if (value === null || typeof value !== "object") return undefined;
397 const rec = value as { projectId?: unknown };
398 return typeof rec.projectId === "string" && rec.projectId.length > 0
399 ? rec.projectId
400 : undefined;
401};
402
403const isGone = (sandbox: CloudSandbox | undefined) =>
404 sandbox === undefined || sandbox.status === "DESTROYED";
405
406const toAttrs = (
407 sandbox: CloudSandbox,
408 fallback?: { projectId?: string },
409): Sandbox["Attributes"] => ({
410 sandboxId: sandbox.id,
411 environmentId: sandbox.environmentId,
412 projectId: fallback?.projectId,
413 region: sandbox.region,
414 status: sandbox.status,
415 idleTimeoutMinutes: sandbox.idleTimeoutMinutes ?? undefined,
416 networkIsolation: sandbox.networkIsolation,
417 createdAt: sandbox.createdAt,
418 domains: [...sandbox.domains],
419});
420
421const toTemplateInput = (template: SandboxTemplate): SandboxTemplateInput => ({
422 ...(template.region !== undefined ? { region: template.region } : {}),
423 ...(template.instructions !== undefined
424 ? { instructions: [...template.instructions] }
425 : {}),
426 ...(template.name !== undefined ? { name: template.name } : {}),
427 ...(template.variables !== undefined
428 ? { variables: template.variables }
429 : {}),
430});
431
432const varsKey = (vars: Record<string, string> | undefined) => {
433 if (vars === undefined) return undefined;
434 const entries = Object.entries(vars).sort(([a], [b]) => a.localeCompare(b));
435 return JSON.stringify(Object.fromEntries(entries));
436};
437
438const templateKey = (template: SandboxTemplate | undefined) => {
439 if (template === undefined) return undefined;
440 return JSON.stringify({
441 region: template.region ?? null,
442 instructions: template.instructions ?? null,
443 name: template.name ?? null,
444 variables: varsKey(template.variables) ?? null,
445 });
446};
447
448const domainsKey = (domains: SandboxDomain[] | undefined) =>
449 JSON.stringify(
450 (domains ?? [])
451 .map(({ port, prefix }) => ({ port, prefix: prefix ?? null }))
452 .sort((a, b) => a.port - b.port),
453 );
454
455const readSandbox = Query.fn((environmentId: string, id: string) =>
456 Railway.sandbox({ environmentId, id }).pipe(Query.map(sandboxFields)),
457);
458
459/** Railway answers a missing sandbox with `null`, not an error. */
460const getById = (environmentId: string, sandboxId: string) =>
461 readSandbox(environmentId, sandboxId).pipe(
462 Effect.map((sandbox) =>
463 sandbox == null || isGone(sandbox) ? undefined : sandbox,
464 ),
465 );
466
467const listSandboxes = (environmentId: string) =>
468 Query.items(
469 Railway.sandboxes({ environmentId, first: 50 }).pipe(
470 Query.map(sandboxFields),
471 ),
472 ).pipe(
473 Stream.filter((sandbox) => !isGone(sandbox)),
474 Stream.runCollect,
475 Effect.map((chunk) => Array.from(chunk)),
476 Effect.catchTag(["RailwayForbidden", "RailwayPlanLimitExceeded"], () =>
477 Effect.succeed([] as CloudSandbox[]),
478 ),
479 );
480
481const waitUntilRunning = (environmentId: string, sandboxId: string) =>
482 Effect.gen(function* () {
483 const sandbox = yield* getById(environmentId, sandboxId);
484 if (sandbox === undefined) {
485 return yield* new SandboxPending({ sandboxId, status: "missing" });
486 }
487 if (sandbox.status === "FAILED") {
488 return yield* new SandboxFailed({
489 sandboxId,
490 status: sandbox.status,
491 });
492 }
493 if (sandbox.status !== "RUNNING") {
494 return yield* new SandboxPending({
495 sandboxId,
496 status: sandbox.status,
497 });
498 }
499 return sandbox;
500 }).pipe(
501 Effect.retry({
502 while: (e) => e._tag === "Railway.SandboxPending",
503 times: 10,
504 schedule: Schedule.spaced("3 seconds"),
505 }),
506 );
507
508const waitUntilGone = (environmentId: string, sandboxId: string) =>
509 waitUntilDeleted(
510 "Sandbox",
511 sandboxId,
512 getById(environmentId, sandboxId).pipe(
513 Effect.map((sandbox) => sandbox === undefined),
514 ),
515 10,
516 );
517
518const sandboxExec = Query.fn(
519 (input: {
520 command: string;
521 environmentId: string;
522 id: string;
523 timeoutSec?: number;
524 }) => {
525 const result = Railway.sandboxExec(input);
526 return {
527 exitCode: result.exitCode,
528 stderr: result.stderr,
529 stdout: result.stdout,
530 timedOut: result.timedOut,
531 truncated: result.truncated,
532 };
533 },
534);
535
536const sandboxHeartbeat = Query.fn((environmentId: string, id: string) =>
537 Railway.sandboxHeartbeat({ environmentId, id }).pipe(
538 Query.map(sandboxFields),
539 ),
540);
541
542const sandboxCheckpointCreate = Query.fn(
543 (input: { environmentId: string; name: string; sandboxId: string }) =>
544 checkpointFields(Railway.sandboxCheckpointCreate(input)),
545);
546
547const sandboxCheckpoints = Query.fn((environmentId: string) =>
548 Railway.sandboxCheckpoints({ environmentId }).pipe(
549 Query.map(checkpointFields),
550 ),
551);
552
553const sandboxCheckpointRename = Query.fn(
554 (input: { environmentId: string; id: string; name: string }) =>
555 checkpointFields(Railway.sandboxCheckpointRename(input)),
556);
557
558const sandboxCheckpointDelete = Query.fn(
559 (input: { environmentId: string; id: string }) =>
560 Railway.sandboxCheckpointDelete(input),
561);
562
563const sandboxCreate = Query.fn((input: SandboxCreateInput) =>
564 sandboxFields(Railway.sandboxCreate({ input })),
565);
566
567const sandboxDestroy = Query.fn((environmentId: string, id: string) =>
568 Railway.sandboxDestroy({ environmentId, id }).pipe(Query.map(sandboxFields)),
569);
570
571/**
572 * Execute a command inside a running sandbox. Does not fail on a
573 * non-zero exit code — inspect `exitCode`.
574 */
575export const execSandbox = Effect.fn(function* (input: {
576 sandboxId: string;
577 environmentId: string;
578 command: string;
579 timeoutSec?: number;
580}) {
581 return yield* sandboxExec({
582 command: input.command,
583 environmentId: input.environmentId,
584 id: input.sandboxId,
585 ...(input.timeoutSec !== undefined ? { timeoutSec: input.timeoutSec } : {}),
586 });
587});
588
589/**
590 * Extend a sandbox's idle timeout from the last interaction.
591 */
592export const heartbeatSandbox = Effect.fn(function* (input: {
593 sandboxId: string;
594 environmentId: string;
595}) {
596 return yield* sandboxHeartbeat(input.environmentId, input.sandboxId);
597});
598
599/**
600 * Capture a running sandbox's disk as a named checkpoint. Synchronous:
601 * the checkpoint is ready when this returns. Reusing a name replaces
602 * the previous checkpoint.
603 */
604export const createSandboxCheckpoint = Effect.fn(function* (input: {
605 sandboxId: string;
606 environmentId: string;
607 name: string;
608}) {
609 return yield* sandboxCheckpointCreate({
610 environmentId: input.environmentId,
611 name: input.name,
612 sandboxId: input.sandboxId,
613 });
614});
615
616/**
617 * List named sandbox checkpoints in an environment (newest first).
618 */
619export const listSandboxCheckpoints = Effect.fn(function* (input: {
620 environmentId: string;
621}) {
622 return yield* sandboxCheckpoints(input.environmentId);
623});
624
625const findCheckpoint = (
626 items: readonly SandboxCheckpointsResultItem[],
627 name: string,
628) => items.find((item) => item.key === name);
629
630/**
631 * Rename a sandbox checkpoint by its current name (`key`).
632 */
633export const renameSandboxCheckpoint = Effect.fn(function* (input: {
634 environmentId: string;
635 name: string;
636 newName: string;
637}) {
638 const items = yield* sandboxCheckpoints(input.environmentId);
639 const found = findCheckpoint(items, input.name);
640 if (found === undefined) {
641 return yield* new SandboxCheckpointNotFound({
642 environmentId: input.environmentId,
643 name: input.name,
644 });
645 }
646 return yield* sandboxCheckpointRename({
647 environmentId: input.environmentId,
648 id: found.id,
649 name: input.newName,
650 });
651});
652
653/**
654 * Delete a sandbox checkpoint by name (`key`). Idempotent if missing:
655 * Railway answers a missing checkpoint with `false`, not an error.
656 */
657export const deleteSandboxCheckpoint = Effect.fn(function* (input: {
658 environmentId: string;
659 name: string;
660}) {
661 const items = yield* sandboxCheckpoints(input.environmentId);
662 const found = findCheckpoint(items, input.name);
663 if (found === undefined) return;
664 yield* sandboxCheckpointDelete({
665 environmentId: input.environmentId,
666 id: found.id,
667 });
668});
669
670export type ExecRequest = {
671 command: string;
672 timeoutSec?: number;
673};
674
675export type ExecResult = Effect.Success<ReturnType<typeof execSandbox>>;
676
677/**
678 * Run a command inside a {@link Sandbox}. Control-plane GraphQL —
679 * provide {@link ExecHttp}. The inner Effect requires
680 * {@link RuntimeContext} so it is typed as runtime-only; from tests
681 * prefer {@link execSandbox}.
682 *
683 *
684 * ### Exec
685 * **Example:** Echo
686 * ```typescript
687 * const run = yield* Railway.Exec(box);
688 * const result = yield* run({ command: "echo hello" });
689 * ```
690 *
691 * @binding
692 * @product Sandbox
693 */
694export interface Exec extends Binding.Service<
695 Exec,
696 "Railway.Sandbox.Exec",
697 (sandbox: SandboxIdentity) => Effect.Effect<ExecClient>
698> {}
699
700export const Exec = Binding.Service<Exec>("Railway.Sandbox.Exec");
701
702export interface ExecClient {
703 (
704 request: ExecRequest,
705 ): Effect.Effect<
706 ExecResult,
707 Effect.Error<ReturnType<typeof execSandbox>>,
708 RuntimeContext
709 >;
710}
711
712/**
713 * HTTP / GraphQL implementation of {@link Exec}.
714 *
715 * @layer
716 * @product Sandbox
717 * @provides Railway.Sandbox.Exec
718 */
719export const ExecHttp = Layer.effect(
720 Exec,
721 Effect.succeed(
722 Effect.fn(function* (sandbox: SandboxIdentity) {
723 const sandboxId = sandbox.sandboxId;
724 const environmentId = sandbox.environmentId;
725 return ((request: ExecRequest) =>
726 execSandbox({
727 sandboxId,
728 environmentId,
729 command: request.command,
730 ...(request.timeoutSec !== undefined
731 ? { timeoutSec: request.timeoutSec }
732 : {}),
733 })) as unknown as ExecClient;
734 }),
735 ),
736);
737
738export const SandboxProvider = () =>
739 Provider.succeed(Sandbox, {
740 stables: ["sandboxId", "environmentId", "createdAt", "region"],
741 nuke: { dependsOn: ["Railway.Project"] },
742
743 diff: Effect.fn(function* ({ news, olds, output }) {
744 if (news === undefined || !isResolved(news)) return undefined;
745 if (output === undefined) return undefined;
746 const nextEnv = environmentIdOf(news.environment);
747 const environmentChanged =
748 nextEnv !== undefined && nextEnv !== output.environmentId;
749 const regionChanged =
750 olds !== undefined
751 ? news.region !== olds.region
752 : news.region !== undefined && news.region !== output.region;
753 const idleChanged =
754 olds !== undefined
755 ? news.idleTimeoutMinutes !== olds.idleTimeoutMinutes
756 : news.idleTimeoutMinutes !== undefined &&
757 news.idleTimeoutMinutes !== output.idleTimeoutMinutes;
758 const isolationChanged =
759 olds !== undefined
760 ? news.networkIsolation !== olds.networkIsolation
761 : news.networkIsolation !== undefined &&
762 news.networkIsolation !== output.networkIsolation;
763 const templateChanged =
764 templateKey(news.template) !== templateKey(olds?.template);
765 const variablesChanged =
766 varsKey(news.variables) !== varsKey(olds?.variables);
767 const domainsChanged =
768 domainsKey(news.publicDomains) !== domainsKey(olds?.publicDomains);
769 const resourcesChanged =
770 news.resources?.cpu !== olds?.resources?.cpu ||
771 news.resources?.memoryGB !== olds?.resources?.memoryGB;
772 const sourceChanged = news.sourceSandboxId !== olds?.sourceSandboxId;
773 if (
774 environmentChanged ||
775 regionChanged ||
776 idleChanged ||
777 isolationChanged ||
778 templateChanged ||
779 variablesChanged ||
780 domainsChanged ||
781 resourcesChanged ||
782 sourceChanged
783 ) {
784 return { action: "replace" as const };
785 }
786 return undefined;
787 }),
788
789 read: Effect.fn(function* ({ olds, output }) {
790 const environmentId =
791 output?.environmentId ??
792 (olds !== undefined ? environmentIdOf(olds.environment) : undefined);
793 const sandboxId = output?.sandboxId;
794 if (environmentId === undefined || sandboxId === undefined) {
795 return undefined;
796 }
797 const found = yield* getById(environmentId, sandboxId);
798 if (found === undefined) return undefined;
799 return toAttrs(found, {
800 projectId:
801 output?.projectId ??
802 (olds !== undefined ? projectIdOf(olds.environment) : undefined),
803 });
804 }),
805
806 list: Effect.fn(function* () {
807 const projects = yield* ownedProjects();
808 const rows = yield* Effect.forEach(projects, (project) =>
809 Effect.gen(function* () {
810 const envIds = yield* projectEnvironmentIds(project);
811 const nested = yield* Effect.forEach(envIds, (environmentId) =>
812 listSandboxes(environmentId).pipe(
813 Effect.map((items) =>
814 items.map((item) =>
815 toAttrs(item, { projectId: project.projectId }),
816 ),
817 ),
818 ),
819 );
820 return nested.flat();
821 }),
822 );
823 const seen = new Set<string>();
824 const unique: Sandbox["Attributes"][] = [];
825 for (const row of rows.flat()) {
826 if (seen.has(row.sandboxId)) continue;
827 seen.add(row.sandboxId);
828 unique.push(row);
829 }
830 return unique;
831 }),
832
833 reconcile: Effect.fn(function* ({ news, output }) {
834 const props = news ?? ({} as SandboxProps);
835 const environmentId =
836 environmentIdOf(props.environment) ?? output?.environmentId;
837 if (environmentId === undefined) {
838 return yield* new SandboxEnvironmentRequired({
839 message:
840 "Sandbox requires a Railway environment (pass a Project, Environment, or { environmentId })",
841 });
842 }
843 const projectId = projectIdOf(props.environment) ?? output?.projectId;
844
845 let current: CloudSandbox | undefined =
846 output?.sandboxId !== undefined && output.sandboxId.length > 0
847 ? yield* getById(environmentId, output.sandboxId)
848 : undefined;
849
850 if (current === undefined) {
851 const created = yield* sandboxCreate({
852 environmentId,
853 ...(props.idleTimeoutMinutes !== undefined
854 ? { idleTimeoutMinutes: props.idleTimeoutMinutes }
855 : {}),
856 ...(props.networkIsolation !== undefined
857 ? { networkIsolation: props.networkIsolation }
858 : {}),
859 ...(props.region !== undefined ? { region: props.region } : {}),
860 ...(props.publicDomains !== undefined
861 ? { publicDomains: props.publicDomains }
862 : {}),
863 ...(props.resources !== undefined
864 ? { resources: props.resources }
865 : {}),
866 ...(props.sourceSandboxId !== undefined
867 ? { sourceSandboxId: props.sourceSandboxId }
868 : {}),
869 ...(props.template !== undefined
870 ? { template: toTemplateInput(props.template) }
871 : {}),
872 ...(props.variables !== undefined
873 ? { variables: props.variables }
874 : {}),
875 });
876 current = isGone(created)
877 ? undefined
878 : created.status === "RUNNING"
879 ? created
880 : yield* waitUntilRunning(environmentId, created.id);
881 } else if (current.status !== "RUNNING" && current.status !== "FAILED") {
882 current = yield* waitUntilRunning(environmentId, current.id);
883 }
884
885 if (current === undefined || isGone(current)) {
886 return yield* new SandboxNotCreated({ environmentId });
887 }
888 if (current.status === "FAILED") {
889 return yield* new SandboxFailed({
890 sandboxId: current.id,
891 status: current.status,
892 });
893 }
894
895 return toAttrs(current, { projectId });
896 }),
897
898 delete: Effect.fn(function* ({ output }) {
899 const sandboxId = output.sandboxId;
900 const environmentId = output.environmentId;
901 if (sandboxId.length === 0 || environmentId.length === 0) return;
902 // A missing sandbox is answered with `null`, not an error.
903 yield* sandboxDestroy(environmentId, sandboxId);
904 yield* waitUntilGone(environmentId, sandboxId);
905 }),
906 });