1import { waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type CloudAgent as RailwayCloudAgent,
6 type CloudAgentStatus,
7} from "@distilled.cloud/railway";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import { Unowned } from "../AdoptPolicy.ts";
11import { isResolved } from "../Diff.ts";
12import * as Provider from "../Provider.ts";
13import { Resource } from "../Resource.ts";
14import {
15 createRailwayName,
16 matchesAlchemyPhysicalName,
17 sanitizeRailwayName,
18} from "./Metadata.ts";
19import { ownedProjects, projectEnvironmentIds } from "./Project.ts";
20import type { Providers } from "./Providers.ts";
21
22const agentFields = <E>(agent: Query<RailwayCloudAgent, E>) => ({
23 id: agent.id,
24 name: agent.name,
25 environmentId: agent.environmentId,
26 projectId: agent.projectId,
27 status: agent.status,
28 domain: agent.domain,
29 domains: agent.domains.pipe(
30 Query.map((item) => ({
31 domain: item.domain,
32 port: item.port,
33 prefix: item.prefix,
34 })),
35 ),
36 consoleTargetId: agent.consoleTargetId,
37 createdAt: agent.createdAt,
38});
39type CloudRow = UnwrapPlan<ReturnType<typeof agentFields>>;
40
41/**
42 * A resource-valued prop: the resource itself, or an Effect that produces
43 * it (so `yield* Project(...)` and `Project(...)` both type-check).
44 */
45type Ref<T> = T | Effect.Effect<T, never, Providers>;
46
47/**
48 * Environment identity a cloud agent is created in. Accepts a
49 * `Railway.Project` (its primary environment), a `Railway.Environment`,
50 * or an `{ environmentId }` stub.
51 */
52export type CloudAgentEnvironment = {
53 readonly environmentId: string;
54 readonly projectId?: string;
55};
56
57export interface CloudAgentProps {
58 /**
59 * Environment the agent VM lives in. Accepts a `Railway.Project`
60 * (primary environment), a `Railway.Environment`, or `{ environmentId }`.
61 * Changing it replaces the agent.
62 */
63 environment: Ref<CloudAgentEnvironment>;
64 /**
65 * Agent name. Unique per environment for this user. If omitted, a
66 * unique name is generated from the stack, stage and logical ID.
67 * Changing it replaces the agent — Railway has no rename API.
68 */
69 name?: string;
70 /**
71 * Variables written to the agent at create time. Values may be
72 * Railway reference templates (`Railway.ref(...)`). Create-only;
73 * ignored on update. Never persisted in attributes.
74 */
75 variables?: Record<string, string>;
76}
77
78export type CloudAgent = Resource<
79 "Railway.CloudAgent",
80 CloudAgentProps,
81 {
82 /** Railway cloud agent id. */
83 cloudAgentId: string;
84 /** Physical agent name. */
85 name: string;
86 /** Environment the agent lives in. */
87 environmentId: string;
88 /** Parent Railway project id. */
89 projectId: string;
90 /** Observed lifecycle status (`STARTING`, `RUNNING`, `SLEEPING`, …). */
91 status: CloudAgentStatus;
92 /**
93 * Public hostname serving port 8080. Stable across sleep and wake;
94 * `undefined` while the machine is asleep.
95 */
96 domain: string | undefined;
97 /** Every public domain on the machine, one per port. */
98 domains: ReadonlyArray<{
99 domain: string;
100 port: number;
101 prefix: string;
102 }>;
103 /** Console / exec target id. `undefined` while unavailable. */
104 consoleTargetId: string | undefined;
105 /** RFC3339 creation timestamp. */
106 createdAt: string;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A Railway.CloudAgent is a persistent coding-agent VM in an
114 * environment. Sleep keeps the disk; wake re-runs the entrypoint.
115 * Cloud agents are Priority Boarding and bill at VM rates while
116 * running.
117 *
118 * @see https://docs.railway.com/cloud-agents
119 *
120 * ### Create a CloudAgent
121 * Pass the Project (or an Environment). Alchemy generates a unique
122 * name unless you pass one. `domain` is the public URL for port 8080.
123 *
124 * **Example:** Next to a Project
125 * ```typescript
126 * const site = yield* Railway.Project("Site");
127 * const agent = yield* Railway.CloudAgent("Coder", {
128 * environment: site,
129 * });
130 * ```
131 *
132 * :::caution[Changing `environment` or `name` replaces the agent]
133 * Railway has no cloud-agent update API. A new VM is created, then
134 * the old one is deleted — the disk is not copied.
135 * :::
136 *
137 * ### A stable name
138 * Pass `name` when you need a stable agent name. Changing it later
139 * replaces the agent.
140 *
141 * **Example:** Explicit name
142 * ```typescript
143 * const agent = yield* Railway.CloudAgent("Coder", {
144 * environment: site,
145 * name: "reviews",
146 * });
147 * ```
148 *
149 * ### Variables
150 * `variables` are create-only. Pair them with a new agent — Railway
151 * does not update variables on an existing VM. Values may reference
152 * other services via `Railway.ref`.
153 *
154 * **Example:** Variables at create
155 * ```typescript
156 * const agent = yield* Railway.CloudAgent("Coder", {
157 * environment: site,
158 * variables: {
159 * DATABASE_URL: Railway.ref(db, "DATABASE_URL"),
160 * },
161 * });
162 * ```
163 *
164 * ### Sleep and wake
165 * Sleep stops compute billing and keeps the disk. Wake re-runs the
166 * entrypoint with files in place. These are not reconciler steps —
167 * call {@link sleepCloudAgent} / {@link wakeCloudAgent}.
168 *
169 * **Example:** Sleep then wake
170 * ```typescript
171 * yield* Railway.sleepCloudAgent(agent);
172 * yield* Railway.wakeCloudAgent(agent);
173 * ```
174 *
175 * ### Module-scope declarations
176 * Resource-valued props accept the resource or an Effect producing it.
177 *
178 * **Example:** Module-scope CloudAgent
179 * ```typescript
180 * // src/agent.ts
181 * import * as Railway from "alchemy/Railway";
182 *
183 * export const Site = Railway.Project("Site");
184 * export const Coder = Railway.CloudAgent("Coder", {
185 * environment: Site,
186 * });
187 * ```
188 *
189 * @resource
190 * @product Cloud Agent
191 */
192export const CloudAgent = Resource<CloudAgent>("Railway.CloudAgent");
193
194export class CloudAgentNotCreated extends Data.TaggedError(
195 "Railway.CloudAgentNotCreated",
196)<{
197 name: string;
198 environmentId: string;
199}> {}
200
201export class CloudAgentEnvironmentRequired extends Data.TaggedError(
202 "Railway.CloudAgentEnvironmentRequired",
203)<{
204 message: string;
205}> {}
206
207const toAttrs = (
208 agent: CloudRow,
209 fallback?: { name?: string; environmentId?: string; projectId?: string },
210): CloudAgent["Attributes"] => ({
211 cloudAgentId: agent.id,
212 name: agent.name || fallback?.name || "",
213 environmentId: agent.environmentId || fallback?.environmentId || "",
214 projectId: agent.projectId || fallback?.projectId || "",
215 status: agent.status,
216 domain: agent.domain ?? undefined,
217 domains: agent.domains.map((item) => ({
218 domain: item.domain,
219 port: item.port,
220 prefix: item.prefix,
221 })),
222 consoleTargetId: agent.consoleTargetId ?? undefined,
223 createdAt: agent.createdAt,
224});
225
226const resolveName = (id: string, name: string | undefined, existing?: string) =>
227 Effect.gen(function* () {
228 if (name !== undefined) return sanitizeRailwayName(name);
229 if (existing !== undefined) return existing;
230 return yield* createRailwayName(id);
231 });
232
233const isGone = (agent: CloudRow | undefined) =>
234 agent === undefined || agent.status === "DELETING";
235
236const environmentIdOf = (value: unknown): string | undefined => {
237 if (value === null || typeof value !== "object") return undefined;
238 const rec = value as { environmentId?: unknown };
239 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
240 ? rec.environmentId
241 : undefined;
242};
243
244const projectIdOf = (value: unknown): string | undefined => {
245 if (value === null || typeof value !== "object") return undefined;
246 const rec = value as { projectId?: unknown };
247 return typeof rec.projectId === "string" && rec.projectId.length > 0
248 ? rec.projectId
249 : undefined;
250};
251
252const cloudAgents = Query.fn((environmentId: string) =>
253 Railway.cloudAgents({ environmentId, mine: true }).pipe(
254 Query.map(agentFields),
255 ),
256);
257
258const listAgents = (environmentId: string) =>
259 cloudAgents(environmentId).pipe(
260 Effect.catchTag(["RailwayForbidden", "RailwayPlanLimitExceeded"], () =>
261 Effect.succeed([] as CloudRow[]),
262 ),
263 );
264
265const cloudAgentCreate = Query.fn(
266 (input: {
267 environmentId: string;
268 name: string;
269 variables?: Record<string, string>;
270 }) => agentFields(Railway.cloudAgentCreate({ input })),
271);
272
273const cloudAgentSleep = Query.fn((id: string) =>
274 agentFields(Railway.cloudAgentSleep({ id })),
275);
276
277const cloudAgentWake = Query.fn((id: string) =>
278 agentFields(Railway.cloudAgentWake({ id })),
279);
280
281const cloudAgentDelete = Query.fn((id: string) =>
282 Railway.cloudAgentDelete({ id }),
283);
284
285const findById = (environmentId: string, id: string) =>
286 listAgents(environmentId).pipe(
287 Effect.map((items) => items.find((agent) => agent.id === id)),
288 );
289
290const findByName = (environmentId: string, name: string) =>
291 listAgents(environmentId).pipe(
292 Effect.map((items) =>
293 items.find((agent) => !isGone(agent) && agent.name === name),
294 ),
295 );
296
297const waitUntilGone = (environmentId: string, cloudAgentId: string) =>
298 waitUntilDeleted(
299 "CloudAgent",
300 cloudAgentId,
301 findById(environmentId, cloudAgentId).pipe(
302 Effect.map((agent) => isGone(agent)),
303 ),
304 );
305
306const cloudAgentIdOf = (
307 agent: { readonly cloudAgentId: string } | string,
308): string => (typeof agent === "string" ? agent : agent.cloudAgentId);
309
310/**
311 * Sleep a running cloud agent. Compute billing stops; the disk is
312 * kept. Waking re-runs the entrypoint.
313 */
314export const sleepCloudAgent = Effect.fn(function* (
315 agent: { readonly cloudAgentId: string } | string,
316) {
317 return yield* cloudAgentSleep(cloudAgentIdOf(agent));
318});
319
320/**
321 * Wake a sleeping cloud agent. Crashed or failed agents cannot be
322 * woken — create a new one instead.
323 */
324export const wakeCloudAgent = Effect.fn(function* (
325 agent: { readonly cloudAgentId: string } | string,
326) {
327 return yield* cloudAgentWake(cloudAgentIdOf(agent));
328});
329
330export const CloudAgentProvider = () =>
331 Provider.succeed(CloudAgent, {
332 stables: ["cloudAgentId", "environmentId", "projectId", "createdAt"],
333 nuke: { dependsOn: ["Railway.Project"] },
334
335 diff: Effect.fn(function* ({ news, output }) {
336 if (news === undefined || !isResolved(news)) return undefined;
337 if (output === undefined) return undefined;
338 const nextEnvironmentId = environmentIdOf(news.environment);
339 const environmentChanged =
340 nextEnvironmentId !== undefined &&
341 nextEnvironmentId !== output.environmentId;
342 const nameChanged =
343 news.name !== undefined &&
344 sanitizeRailwayName(news.name) !== output.name;
345 if (environmentChanged || nameChanged) {
346 return { action: "replace" as const };
347 }
348 return undefined;
349 }),
350
351 read: Effect.fn(function* ({ id, olds, output }) {
352 const name = yield* resolveName(id, olds?.name, output?.name);
353 const environmentId =
354 output?.environmentId ??
355 (olds !== undefined ? environmentIdOf(olds.environment) : undefined);
356 if (environmentId === undefined) return undefined;
357 const found =
358 (output?.cloudAgentId !== undefined
359 ? yield* findById(environmentId, output.cloudAgentId)
360 : undefined) ?? (yield* findByName(environmentId, name));
361 if (found === undefined || isGone(found)) return undefined;
362 const attrs = toAttrs(found, {
363 name,
364 environmentId,
365 projectId:
366 output?.projectId ??
367 (olds !== undefined ? projectIdOf(olds.environment) : undefined),
368 });
369 if (output !== undefined) return attrs;
370 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
371 }),
372
373 list: Effect.fn(function* () {
374 const projects = yield* ownedProjects();
375 const rows = yield* Effect.forEach(projects, (project) =>
376 Effect.gen(function* () {
377 const envIds = yield* projectEnvironmentIds(project);
378 const nested = yield* Effect.forEach(envIds, (environmentId) =>
379 listAgents(environmentId).pipe(
380 Effect.map((agents) =>
381 agents
382 .filter(
383 (agent) =>
384 !isGone(agent) && matchesAlchemyPhysicalName(agent.name),
385 )
386 .map((agent) =>
387 toAttrs(agent, {
388 environmentId,
389 projectId: project.projectId,
390 }),
391 ),
392 ),
393 ),
394 );
395 return nested.flat();
396 }),
397 );
398 const seen = new Set<string>();
399 const unique: CloudAgent["Attributes"][] = [];
400 for (const row of rows.flat()) {
401 if (seen.has(row.cloudAgentId)) continue;
402 seen.add(row.cloudAgentId);
403 unique.push(row);
404 }
405 return unique;
406 }),
407
408 reconcile: Effect.fn(function* ({ id, news, output }) {
409 const props = news ?? ({} as CloudAgentProps);
410 const environmentId =
411 environmentIdOf(props.environment) ?? output?.environmentId;
412 if (environmentId === undefined) {
413 return yield* new CloudAgentEnvironmentRequired({
414 message:
415 "CloudAgent requires a resolved Railway.Project or Railway.Environment",
416 });
417 }
418 const name = yield* resolveName(id, props.name, output?.name);
419 const projectId =
420 projectIdOf(props.environment) ?? output?.projectId ?? "";
421
422 let current =
423 output?.cloudAgentId !== undefined
424 ? yield* findById(environmentId, output.cloudAgentId)
425 : undefined;
426 if (current === undefined || isGone(current)) {
427 current = yield* findByName(environmentId, name);
428 }
429
430 if (current === undefined || isGone(current)) {
431 const created = yield* cloudAgentCreate({
432 environmentId,
433 name,
434 ...(props.variables !== undefined
435 ? { variables: props.variables }
436 : {}),
437 }).pipe(
438 Effect.catchTag("RailwayValidationError", () =>
439 Effect.succeed(undefined),
440 ),
441 );
442 current =
443 created !== undefined && !isGone(created)
444 ? created
445 : yield* findByName(environmentId, name);
446 }
447
448 if (current === undefined || isGone(current)) {
449 return yield* new CloudAgentNotCreated({ name, environmentId });
450 }
451
452 return toAttrs(current, { name, environmentId, projectId });
453 }),
454
455 delete: Effect.fn(function* ({ output }) {
456 const cloudAgentId = output.cloudAgentId;
457 if (cloudAgentId.length === 0) return;
458 yield* cloudAgentDelete(cloudAgentId);
459 if (output.environmentId.length > 0) {
460 yield* waitUntilGone(output.environmentId, cloudAgentId);
461 }
462 }),
463 });