1import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
2import * as GcpRetry from "@distilled.cloud/gcp/Retry";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schedule from "effect/Schedule";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import { createInternalLabels } from "../Labels.ts";
13import type { Providers } from "../Providers.ts";
14import { listLocations } from "./names.ts";
15import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
16import { listAlchemyReasoningEngines } from "./ReasoningEngine.ts";
17import {
18 AiPlatformNotResolved,
19 AiPlatformStillExists,
20 collectPages,
21 encodeDisplayName,
22 hasDisplayNameOwnership,
23 lastSegment,
24 locationParent,
25 normalizeLocation,
26 ownedById,
27 parentBefore,
28 parseDisplayName,
29 parseResourceName,
30} from "./shared.ts";
31
32const COLLECTION = "sandboxEnvironments";
33
34export type SandboxEnvironmentSpec = {
35 /** Code execution environment. */
36 codeExecutionEnvironment?: {
37 machineConfig?: string;
38 codeLanguage?: string;
39 };
40};
41
42export type ReasoningEnginesSandboxEnvironmentProps = {
43 /**
44 * Parent Reasoning Engine. Full name or engine id. Immutable —
45 * changing it replaces the sandbox.
46 */
47 reasoningEngine: string;
48 /**
49 * Vertex AI location. Used when `reasoningEngine` is a bare id.
50 * Immutable. @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * Display name. Required by the API. SandboxEnvironment has no labels
55 * field, so Alchemy ownership is stored in a compact `[alc …]`
56 * displayName prefix for `list` / nuke.
57 */
58 displayName?: string;
59 /**
60 * Template name (id under the parent engine) to create from.
61 */
62 sandboxEnvironmentTemplate?: string;
63 /**
64 * Snapshot resource name to restore from.
65 */
66 sandboxEnvironmentSnapshot?: string;
67 /**
68 * Sandbox spec (code execution environment).
69 */
70 spec?: SandboxEnvironmentSpec;
71 /**
72 * TTL (e.g. `"3600s"`). Input only.
73 */
74 ttl?: string;
75 /**
76 * Owner identity. A sandbox can only restore snapshots of the same owner.
77 */
78 owner?: string;
79};
80
81export type ReasoningEnginesSandboxEnvironment = Resource<
82 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironment",
83 ReasoningEnginesSandboxEnvironmentProps,
84 {
85 /** Full resource name. */
86 name: string;
87 /** Sandbox id (last path segment). */
88 sandboxEnvironmentId: string;
89 /** Parent Reasoning Engine resource name. */
90 reasoningEngine: string;
91 /** Project id. */
92 project: string;
93 /** Location id. */
94 location: string;
95 /** User display name with the Alchemy ownership prefix stripped. */
96 displayName: string | undefined;
97 /** Runtime state. */
98 state: string | undefined;
99 /** Template name, if created from a template. */
100 sandboxEnvironmentTemplate: string | undefined;
101 /** Latest snapshot resource name. */
102 latestSandboxEnvironmentSnapshot: string | undefined;
103 /** Load-balancer hostname. */
104 loadBalancerHostname: string | undefined;
105 /** RFC3339 expire time. */
106 expireTime: string | undefined;
107 /** RFC3339 creation timestamp. */
108 createTime: string | undefined;
109 /** RFC3339 last-update timestamp. */
110 updateTime: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A Vertex AI sandbox environment — a containerized secure execution
118 * runtime for Agent Engine workloads.
119 *
120 * There is no update API. Changing parent or location replaces the
121 * sandbox. Ownership is stamped into the display name.
122 *
123 * ### Creating a Sandbox
124 * **Example:** From a template
125 * ```typescript
126 * const sandbox = yield* GCP.AIPlatform.ReasoningEnginesSandboxEnvironment(
127 * "Box",
128 * {
129 * reasoningEngine: engine.name,
130 * displayName: "dev-box",
131 * sandboxEnvironmentTemplate: template.sandboxEnvironmentTemplateId,
132 * ttl: "3600s",
133 * },
134 * );
135 * ```
136 *
137 * @resource
138 * @category AIPlatform
139 */
140export const ReasoningEnginesSandboxEnvironment =
141 Resource<ReasoningEnginesSandboxEnvironment>(
142 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironment",
143 );
144
145export class ReasoningEnginesSandboxEnvironmentNotResolved extends Data.TaggedError(
146 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentNotResolved",
147)<{
148 name: string;
149}> {}
150
151const engineNameOf = (
152 project: string,
153 location: string,
154 reasoningEngine: string,
155) =>
156 reasoningEngine.includes("/")
157 ? reasoningEngine
158 : `${locationParent(project, location)}/reasoningEngines/${reasoningEngine}`;
159
160const toAttrs = (
161 sandbox: aiplatform.GoogleCloudAiplatformV1SandboxEnvironment,
162 project: string,
163) => {
164 const name = sandbox.name ?? "";
165 const parsed = parseResourceName(name, COLLECTION);
166 const display = parseDisplayName(sandbox.displayName);
167 return {
168 name,
169 sandboxEnvironmentId: parsed.id,
170 reasoningEngine: parentBefore(name, COLLECTION),
171 project: parsed.project || project,
172 location: parsed.location,
173 displayName: display.displayName,
174 state: sandbox.state,
175 sandboxEnvironmentTemplate: sandbox.sandboxEnvironmentTemplate,
176 latestSandboxEnvironmentSnapshot: sandbox.latestSandboxEnvironmentSnapshot,
177 loadBalancerHostname: sandbox.connectionInfo?.loadBalancerHostname,
178 expireTime: sandbox.expireTime,
179 createTime: sandbox.createTime,
180 updateTime: sandbox.updateTime,
181 };
182};
183
184const getByName = (name: string) =>
185 name.length === 0
186 ? Effect.succeed(undefined)
187 : aiplatform.getReasoningEnginesSandboxEnvironments({ name }).pipe(
188 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
189 Effect.catchTag("SandboxEnvironmentsNotEnabled", () =>
190 Effect.succeed(undefined),
191 ),
192 );
193
194const waitUntilExists = (name: string) =>
195 getByName(name).pipe(
196 Effect.filterOrFail(
197 (
198 sandbox,
199 ): sandbox is aiplatform.GoogleCloudAiplatformV1SandboxEnvironment =>
200 sandbox !== undefined,
201 () => new AiPlatformNotResolved({ name }),
202 ),
203 Effect.retry({
204 while: (error) => error._tag === "GCP.AIPlatform.NotResolved",
205 times: 8,
206 schedule: Schedule.spaced("2 seconds"),
207 }),
208 );
209
210const waitUntilGone = (name: string) =>
211 getByName(name).pipe(
212 Effect.filterOrFail(
213 (sandbox) => sandbox === undefined,
214 () => new AiPlatformStillExists({ name }),
215 ),
216 Effect.asVoid,
217 Effect.retry({
218 while: (error) => error._tag === "GCP.AIPlatform.StillExists",
219 times: 10,
220 schedule: Schedule.spaced("2 seconds"),
221 }),
222 );
223
224const listSandboxes = (parent: string) =>
225 collectPages(
226 aiplatform.listReasoningEnginesSandboxEnvironments.pages({
227 parent,
228 pageSize: 100,
229 }),
230 ).pipe(
231 Effect.map((pages) =>
232 pages.flatMap((page) => page.sandboxEnvironments ?? []),
233 ),
234 Effect.catchTag("NotFound", () =>
235 Effect.succeed<aiplatform.GoogleCloudAiplatformV1SandboxEnvironment[]>(
236 [],
237 ),
238 ),
239 );
240
241const findOwned = (parent: string, id: string) =>
242 Effect.gen(function* () {
243 const sandboxes = yield* listSandboxes(parent);
244 for (const sandbox of sandboxes) {
245 const parsed = parseDisplayName(sandbox.displayName);
246 if (yield* ownedById(id, parsed.labels)) {
247 return sandbox;
248 }
249 }
250 return undefined;
251 });
252
253export const ReasoningEnginesSandboxEnvironmentProvider = () =>
254 Provider.succeed(ReasoningEnginesSandboxEnvironment, {
255 stables: [
256 "name",
257 "sandboxEnvironmentId",
258 "reasoningEngine",
259 "project",
260 "location",
261 "createTime",
262 ],
263
264 diff: Effect.fn(function* ({ news, olds, output }) {
265 const env = yield* GcpEnvironment.current;
266 if (!isResolved(news)) return undefined;
267 const previousParent = lastSegment(
268 olds?.reasoningEngine ?? output?.reasoningEngine ?? "",
269 );
270 const nextParent = lastSegment(news.reasoningEngine);
271 const previousLocation = normalizeLocation(
272 olds?.location ?? output?.location,
273 env.region,
274 );
275 const nextLocation = normalizeLocation(
276 news.location ?? output?.location,
277 env.region,
278 );
279 const templateChanged =
280 (news.sandboxEnvironmentTemplate ?? "") !==
281 (olds?.sandboxEnvironmentTemplate ?? "");
282 const replace =
283 (previousParent.length > 0 && previousParent !== nextParent) ||
284 previousLocation !== nextLocation ||
285 (olds !== undefined && templateChanged);
286 if (!replace) return undefined;
287 return {
288 action: "replace" as const,
289 deleteFirst:
290 previousLocation === nextLocation && previousParent === nextParent,
291 };
292 }),
293
294 read: Effect.fn(function* ({ id, olds, output }) {
295 const env = yield* GcpEnvironment.current;
296 const location = normalizeLocation(
297 olds?.location ?? output?.location,
298 env.region,
299 );
300 const parentRef = olds?.reasoningEngine ?? output?.reasoningEngine;
301 // A create interrupted before its parent resolved has nothing to find.
302 if (output?.name === undefined && typeof parentRef !== "string") {
303 return undefined;
304 }
305 const parent = engineNameOf(env.project, location, parentRef ?? "");
306 const existing =
307 output?.name !== undefined
308 ? yield* getByName(output.name)
309 : yield* findOwned(parent, id);
310 if (existing === undefined) return undefined;
311 const attrs = toAttrs(existing, env.project);
312 const parsed = parseDisplayName(existing.displayName);
313 return (yield* ownedById(id, parsed.labels)) ? attrs : Unowned(attrs);
314 }),
315
316 list: () =>
317 Effect.gen(function* () {
318 const env = yield* GcpEnvironment.current;
319 const engines = (yield* Effect.forEach(
320 listLocations(env.region),
321 (location) => listAlchemyReasoningEngines(env.project, location),
322 )).flat();
323 const sandboxes = yield* Effect.forEach(
324 engines,
325 (engine) =>
326 engine.name
327 ? listSandboxes(engine.name)
328 : Effect.succeed<
329 aiplatform.GoogleCloudAiplatformV1SandboxEnvironment[]
330 >([]),
331 { concurrency: 4 },
332 );
333 return sandboxes
334 .flat()
335 .filter((sandbox) => hasDisplayNameOwnership(sandbox.displayName))
336 .map((sandbox) => toAttrs(sandbox, env.project));
337 }),
338
339 reconcile: Effect.fn(function* ({ id, news, output }) {
340 const env = yield* GcpEnvironment.current;
341 const location = normalizeLocation(
342 news.location ?? output?.location,
343 env.region,
344 );
345 const parent = engineNameOf(env.project, location, news.reasoningEngine);
346 const internal = yield* createInternalLabels(id);
347 const displayName = encodeDisplayName(
348 internal,
349 news.displayName ?? "sandbox",
350 );
351
352 let current =
353 output?.name !== undefined
354 ? yield* getByName(output.name)
355 : yield* findOwned(parent, id);
356
357 if (current === undefined) {
358 const created = yield* aiplatform
359 .createReasoningEnginesSandboxEnvironments({
360 parent,
361 body: {
362 displayName,
363 sandboxEnvironmentTemplate: news.sandboxEnvironmentTemplate,
364 sandboxEnvironmentSnapshot: news.sandboxEnvironmentSnapshot,
365 spec: news.spec,
366 ttl: news.ttl,
367 owner: news.owner,
368 },
369 })
370 .pipe(
371 GcpRetry.none,
372 Effect.catchTag("SandboxEnvironmentsNotEnabled", () =>
373 Effect.succeed(undefined),
374 ),
375 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
376 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
377 Effect.timeoutOption("20 seconds"),
378 );
379 const createdOp = Option.getOrUndefined(created);
380 if (createdOp !== undefined) {
381 yield* waitForOperation(createdOp, { alreadyExistsOk: true });
382 }
383 const createdName =
384 createdOp !== undefined
385 ? resourceNameFromOperation(createdOp)
386 : undefined;
387 current =
388 createdName !== undefined
389 ? yield* waitUntilExists(createdName)
390 : yield* findOwned(parent, id);
391 }
392
393 if (current === undefined || current.name === undefined) {
394 return yield* new ReasoningEnginesSandboxEnvironmentNotResolved({
395 name: output?.name ?? parent,
396 });
397 }
398
399 return toAttrs(current, env.project);
400 }),
401
402 delete: Effect.fn(function* ({ output }) {
403 const operation = yield* aiplatform
404 .deleteReasoningEnginesSandboxEnvironments({
405 name: output.name,
406 })
407 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
408 if (operation !== undefined) {
409 yield* waitForOperation(operation, { notFoundOk: true });
410 }
411 yield* waitUntilGone(output.name);
412 }),
413 });