1import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { createInternalLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 compact,
15 encodeDisplayName,
16 hasOwnershipMarker,
17 lastSegment,
18 normalizeLocation,
19 parentOf,
20 ownsDisplayName,
21 parseDisplayName,
22 parseName,
23 listLocations,
24} from "./names.ts";
25import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
26
27export type SandboxEnvironmentTemplateDefaultContainerEnvironment =
28 aiplatform.GoogleCloudAiplatformV1SandboxEnvironmentTemplateDefaultContainerEnvironment;
29export type SandboxEnvironmentTemplateCustomContainerEnvironment =
30 aiplatform.GoogleCloudAiplatformV1SandboxEnvironmentTemplateCustomContainerEnvironment;
31export type SandboxEnvironmentTemplateEgressControlConfig =
32 aiplatform.GoogleCloudAiplatformV1SandboxEnvironmentTemplateEgressControlConfig;
33
34export type ReasoningEnginesSandboxEnvironmentTemplateProps = {
35 /**
36 * Parent Reasoning Engine. Full name
37 * `projects/{project}/locations/{location}/reasoningEngines/{engine}`
38 * or the engine id (combined with `location`). Immutable — changing it
39 * replaces the template.
40 */
41 reasoningEngine: string;
42 /**
43 * Vertex AI location. Used when `reasoningEngine` is a bare id.
44 * Immutable. @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * Display name. Required by Vertex. SandboxEnvironmentTemplate has no
49 * labels field, so Alchemy ownership is stored in a `[alchemy …]`
50 * prefix for `list` / nuke.
51 */
52 displayName?: string;
53 /**
54 * Default container environment.
55 */
56 defaultContainerEnvironment?: SandboxEnvironmentTemplateDefaultContainerEnvironment;
57 /**
58 * Custom (BYOC) container environment.
59 */
60 customContainerEnvironment?: SandboxEnvironmentTemplateCustomContainerEnvironment;
61 /**
62 * Egress control (internet access).
63 */
64 egressControlConfig?: SandboxEnvironmentTemplateEgressControlConfig;
65};
66
67export type ReasoningEnginesSandboxEnvironmentTemplate = Resource<
68 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentTemplate",
69 ReasoningEnginesSandboxEnvironmentTemplateProps,
70 {
71 /** Full resource name. */
72 name: string;
73 /** Template id (last path segment). */
74 sandboxEnvironmentTemplateId: string;
75 /** Parent Reasoning Engine resource name. */
76 reasoningEngine: string;
77 /** Location id. */
78 location: string;
79 /** Project id. */
80 project: string;
81 /** User display name with the Alchemy ownership prefix stripped. */
82 displayName: string | undefined;
83 /** Server-reported state. */
84 state: string | undefined;
85 /** RFC3339 creation timestamp. */
86 createTime: string | undefined;
87 /** RFC3339 last-update timestamp. */
88 updateTime: string | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * A Vertex AI sandbox environment template — the blueprint used to
96 * create Agent Engine sandbox environments.
97 *
98 * There is no update API. Changing parent or location replaces the
99 * template. Ownership is stamped into the display name.
100 *
101 * ### Creating a Template
102 * **Example:** Default computer-use container
103 * ```typescript
104 * const template = yield* GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentTemplate(
105 * "Sandbox",
106 * {
107 * reasoningEngine: engine.name,
108 * displayName: "computer-use",
109 * defaultContainerEnvironment: {
110 * defaultContainerCategory: "DEFAULT_CONTAINER_CATEGORY_COMPUTER_USE",
111 * },
112 * },
113 * );
114 * ```
115 *
116 * @resource
117 * @category AIPlatform
118 */
119export const ReasoningEnginesSandboxEnvironmentTemplate =
120 Resource<ReasoningEnginesSandboxEnvironmentTemplate>(
121 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentTemplate",
122 );
123
124export class ReasoningEnginesSandboxEnvironmentTemplateNotResolved extends Data.TaggedError(
125 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentTemplateNotResolved",
126)<{
127 name: string;
128}> {}
129
130export class ReasoningEnginesSandboxEnvironmentTemplateStillExists extends Data.TaggedError(
131 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentTemplateStillExists",
132)<{
133 name: string;
134}> {}
135
136const engineNameOf = (
137 project: string,
138 location: string,
139 reasoningEngine: string,
140) =>
141 reasoningEngine.includes("/")
142 ? reasoningEngine
143 : `${parentOf(project, location)}/reasoningEngines/${reasoningEngine}`;
144
145const toAttrs = (
146 template: aiplatform.GoogleCloudAiplatformV1SandboxEnvironmentTemplate,
147 project: string,
148) => {
149 const name = template.name ?? "";
150 const parsed = parseName(name, "sandboxEnvironmentTemplates");
151 const display = parseDisplayName(template.displayName);
152 const parts = name.split("/").filter((part) => part.length > 0);
153 const enginesAt = parts.lastIndexOf("reasoningEngines");
154 const reasoningEngine =
155 enginesAt >= 0
156 ? parts.slice(0, enginesAt + 2).join("/")
157 : name.replace(/\/sandboxEnvironmentTemplates\/[^/]+$/, "");
158 return {
159 name,
160 sandboxEnvironmentTemplateId: parsed.resourceId,
161 reasoningEngine,
162 location: parsed.location,
163 project: parsed.project || project,
164 displayName: display.displayName,
165 state: template.state,
166 createTime: template.createTime,
167 updateTime: template.updateTime,
168 };
169};
170
171const getByName = (name: string) =>
172 name.length === 0
173 ? Effect.succeed(undefined)
174 : aiplatform
175 .getProjectsLocationsReasoningEnginesSandboxEnvironmentTemplates({
176 name,
177 })
178 .pipe(
179 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
180 Effect.catchTag("SandboxEnvironmentsNotEnabled", () =>
181 Effect.succeed(undefined),
182 ),
183 );
184
185const listAt = (parent: string) =>
186 aiplatform.listProjectsLocationsReasoningEnginesSandboxEnvironmentTemplates
187 .pages({ parent, pageSize: 100 })
188 .pipe(
189 Stream.flatMap((page) =>
190 Stream.fromIterable(page.sandboxEnvironmentTemplates ?? []),
191 ),
192 Stream.take(500),
193 Stream.runCollect,
194 Effect.map((chunk) => Array.from(chunk)),
195 Effect.catchTag("NotFound", () => Effect.succeed([])),
196 );
197
198const findOwned = (id: string, parent: string) =>
199 Effect.gen(function* () {
200 const templates = yield* listAt(parent);
201 for (const template of templates) {
202 const parsed = parseDisplayName(template.displayName);
203 if (yield* ownsDisplayName(id, template.displayName)) {
204 return template;
205 }
206 }
207 return undefined;
208 });
209
210const waitUntilGone = (name: string) =>
211 getByName(name).pipe(
212 Effect.flatMap((template) =>
213 template === undefined
214 ? Effect.void
215 : Effect.fail(
216 new ReasoningEnginesSandboxEnvironmentTemplateStillExists({
217 name,
218 }),
219 ),
220 ),
221 Effect.retry({
222 while: (error) =>
223 error._tag ===
224 "GCP.AIPlatform.ReasoningEnginesSandboxEnvironmentTemplateStillExists",
225 times: 10,
226 schedule: Schedule.spaced("2 seconds"),
227 }),
228 );
229
230const listEngines = (parent: string) =>
231 aiplatform.listProjectsLocationsReasoningEngines
232 .pages({ parent, pageSize: 100 })
233 .pipe(
234 Stream.flatMap((page) =>
235 Stream.fromIterable(page.reasoningEngines ?? []),
236 ),
237 Stream.take(200),
238 Stream.runCollect,
239 Effect.map((chunk) => Array.from(chunk)),
240 Effect.catchTag("NotFound", () => Effect.succeed([])),
241 );
242
243export const ReasoningEnginesSandboxEnvironmentTemplateProvider = () =>
244 Provider.succeed(ReasoningEnginesSandboxEnvironmentTemplate, {
245 stables: [
246 "name",
247 "sandboxEnvironmentTemplateId",
248 "reasoningEngine",
249 "location",
250 "project",
251 "createTime",
252 ],
253
254 diff: Effect.fn(function* ({ news, olds, output }) {
255 const env = yield* GcpEnvironment.current;
256 if (!isResolved(news)) return undefined;
257 const previousParent = lastSegment(
258 olds?.reasoningEngine ?? output?.reasoningEngine ?? "",
259 );
260 const nextParent = lastSegment(news.reasoningEngine);
261 const previousLocation = normalizeLocation(
262 olds?.location ?? output?.location,
263 env.region,
264 );
265 const nextLocation = normalizeLocation(
266 news.location ?? output?.location,
267 env.region,
268 );
269 if (
270 (previousParent.length > 0 && previousParent !== nextParent) ||
271 previousLocation !== nextLocation
272 ) {
273 return { action: "replace" as const, deleteFirst: false };
274 }
275 return undefined;
276 }),
277
278 read: Effect.fn(function* ({ id, olds, output }) {
279 const env = yield* GcpEnvironment.current;
280 const location = normalizeLocation(
281 olds?.location ?? output?.location,
282 env.region,
283 );
284 const parentRef = olds?.reasoningEngine ?? output?.reasoningEngine;
285 // A create interrupted before its parent resolved has nothing to find.
286 if (output?.name === undefined && typeof parentRef !== "string") {
287 return undefined;
288 }
289 const parent = engineNameOf(env.project, location, parentRef ?? "");
290 const existing =
291 (output?.name !== undefined
292 ? yield* getByName(output.name)
293 : undefined) ?? (yield* findOwned(id, parent));
294 if (existing === undefined) return undefined;
295 const attrs = toAttrs(existing, env.project);
296 const parsed = parseDisplayName(existing.displayName);
297 return (yield* ownsDisplayName(id, existing.displayName))
298 ? attrs
299 : Unowned(attrs);
300 }),
301
302 list: () =>
303 Effect.gen(function* () {
304 const env = yield* GcpEnvironment.current;
305 const engines = (yield* Effect.forEach(
306 listLocations(env.region),
307 (location) => listEngines(parentOf(env.project, location)),
308 )).flat();
309 const templates = yield* Effect.forEach(
310 engines,
311 (engine) => (engine.name ? listAt(engine.name) : Effect.succeed([])),
312 { concurrency: 4 },
313 );
314 return templates
315 .flat()
316 .filter((template) => hasOwnershipMarker(template.displayName))
317 .map((template) => toAttrs(template, env.project));
318 }),
319
320 reconcile: Effect.fn(function* ({ id, news, output }) {
321 const env = yield* GcpEnvironment.current;
322 const location = normalizeLocation(
323 news.location ?? output?.location,
324 env.region,
325 );
326 const parent = engineNameOf(env.project, location, news.reasoningEngine);
327 const internal = yield* createInternalLabels(id);
328 const displayName = encodeDisplayName(
329 internal,
330 news.displayName ?? "sandbox-template",
331 );
332
333 let current =
334 (output?.name !== undefined
335 ? yield* getByName(output.name)
336 : undefined) ?? (yield* findOwned(id, parent));
337
338 if (current === undefined) {
339 const created = yield* aiplatform
340 .createProjectsLocationsReasoningEnginesSandboxEnvironmentTemplates({
341 parent,
342 body: compact({
343 displayName,
344 defaultContainerEnvironment: news.defaultContainerEnvironment,
345 customContainerEnvironment: news.customContainerEnvironment,
346 egressControlConfig: news.egressControlConfig,
347 }),
348 })
349 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
350 const done =
351 created !== undefined
352 ? yield* waitForOperation(created, { alreadyExistsOk: true })
353 : undefined;
354 const createdName =
355 done !== undefined ? resourceNameFromOperation(done) : undefined;
356 current =
357 createdName !== undefined
358 ? yield* getByName(createdName)
359 : yield* findOwned(id, parent);
360 }
361
362 if (current === undefined || current.name === undefined) {
363 return yield* new ReasoningEnginesSandboxEnvironmentTemplateNotResolved(
364 { name: output?.name ?? parent },
365 );
366 }
367 return toAttrs(current, env.project);
368 }),
369
370 delete: Effect.fn(function* ({ output }) {
371 const operation = yield* aiplatform
372 .deleteProjectsLocationsReasoningEnginesSandboxEnvironmentTemplates({
373 name: output.name,
374 })
375 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
376 if (operation !== undefined) {
377 yield* waitForOperation(operation, { notFoundOk: true });
378 }
379 yield* waitUntilGone(output.name);
380 }),
381 });