1import { Query } from "@distilled.cloud/core/query";
2import { Railway as RailwayApi } from "@distilled.cloud/railway";
3import * as Provider from "@/Provider";
4import * as Railway from "@/Railway";
5import { suitePartition } from "./suiteProject.ts";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Result from "effect/Result";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Railway.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20// Cloud agents are Priority Boarding. Unentitled workspaces reject
21// `cloudAgentCreate` with a typed plan/auth tag. The probe always runs
22// and pins that tag. When the token is entitled the same test continues
23// through the resource create+list+delete lifecycle.
24
25// `cloudAgents` declares no not-found error; a missing environment
26// surfaces as an empty list.
27const listLive = Query.fn((environmentId: string) =>
28 RailwayApi.cloudAgents({ environmentId, mine: true }).pipe(
29 Query.map((agent) => ({
30 id: agent.id,
31 name: agent.name,
32 status: agent.status,
33 environmentId: agent.environmentId,
34 projectId: agent.projectId,
35 })),
36 ),
37);
38
39const waitUntilAgentGone = (environmentId: string, cloudAgentId: string) =>
40 listLive(environmentId).pipe(
41 Effect.map((items) =>
42 items.some(
43 (agent) => agent.id === cloudAgentId && agent.status !== "DELETING",
44 )
45 ? ("found" as const)
46 : ("gone" as const),
47 ),
48 Effect.repeat({
49 schedule: Schedule.spaced("1 second"),
50 until: (status) => status === "gone",
51 times: 10,
52 }),
53 );
54
55const deleteAgent = Query.fn((id: string) =>
56 RailwayApi.cloudAgentDelete({ id }),
57);
58
59const createCloudAgent = Query.fn(
60 (input: { environmentId: string; name: string }) => {
61 const agent = RailwayApi.cloudAgentCreate({ input });
62 return { id: agent.id };
63 },
64);
65
66test.provider(
67 "create, list, and delete a cloud agent",
68 (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71
72 const projectOnly = yield* stack.deploy(
73 Effect.gen(function* () {
74 const { project, environment } = yield* suitePartition;
75 return { project, environment };
76 }),
77 );
78
79 const probe = yield* Effect.result(
80 createCloudAgent({
81 environmentId: projectOnly.environment.environmentId,
82 name: projectOnly.project.name,
83 }),
84 );
85 if (Result.isFailure(probe)) {
86 expect(
87 ["RailwayForbidden", "RailwayPlanLimitExceeded"].includes(
88 probe.failure._tag,
89 ),
90 ).toEqual(true);
91 yield* stack.destroy();
92 return;
93 }
94
95 yield* deleteAgent(probe.success.id);
96 yield* waitUntilAgentGone(
97 projectOnly.environment.environmentId,
98 probe.success.id,
99 );
100
101 const created = yield* stack.deploy(
102 Effect.gen(function* () {
103 const { project, environment } = yield* suitePartition;
104 const agent = yield* Railway.CloudAgent("Coder", {
105 environment,
106 });
107 return { project, environment, agent };
108 }),
109 );
110
111 expect(created.agent.cloudAgentId).toEqual(expect.any(String));
112 expect(created.agent.cloudAgentId.length).toBeGreaterThan(0);
113 expect(created.agent.environmentId).toEqual(
114 created.environment.environmentId,
115 );
116 expect(created.agent.projectId).toEqual(created.project.projectId);
117 expect(created.agent.name).toEqual(expect.any(String));
118 expect(created.agent.name.length).toBeGreaterThan(0);
119 expect(created.agent.name.length).toBeLessThanOrEqual(32);
120 expect(created.agent.name).toMatch(/^[a-z][a-z0-9-]*$/);
121 expect(created.agent.createdAt).toEqual(expect.any(String));
122 expect(created.agent.status).toEqual(expect.any(String));
123 expect(
124 [
125 "CRASHED",
126 "DELETING",
127 "FAILED",
128 "RUNNING",
129 "SLEEPING",
130 "STARTING",
131 ].includes(created.agent.status),
132 ).toEqual(true);
133
134 const listed = yield* listLive(created.environment.environmentId);
135 const fetched = listed.find(
136 (agent) => agent.id === created.agent.cloudAgentId,
137 );
138 expect(fetched).toBeDefined();
139 expect(fetched?.name).toEqual(created.agent.name);
140 expect(fetched?.environmentId).toEqual(created.environment.environmentId);
141 expect(fetched?.projectId).toEqual(created.project.projectId);
142
143 const provider = yield* Provider.findProvider(Railway.CloudAgent);
144 const fromProvider = yield* provider.list();
145 const found = fromProvider.find(
146 (agent) => agent.cloudAgentId === created.agent.cloudAgentId,
147 );
148 expect(found).toBeDefined();
149 expect(found?.name).toEqual(created.agent.name);
150 expect(found?.environmentId).toEqual(created.environment.environmentId);
151
152 yield* stack.destroy();
153
154 const agentGone = yield* waitUntilAgentGone(
155 created.environment.environmentId,
156 created.agent.cloudAgentId,
157 );
158 expect(agentGone).toEqual("gone");
159 }).pipe(logLevel),
160 {
161 tags: [
162 "provider:railway",
163 "provider:railway:cloudagent",
164 "provider:railway:project",
165 "provider:railway:projectenvironment",
166 "live",
167 ],
168 timeout: 120_000,
169 },
170);