1import * as ces from "@distilled.cloud/gcp/ces_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 DEFAULT_OPENAPI_SCHEMA,
13 expandApp,
14 forEachApp,
15 normalizeLocation,
16 parseOwnership,
17 parseResourceName,
18 replaceOnIdentity,
19 retryTransient,
20 sameJson,
21 sameText,
22 toPhysicalId,
23 toolsetKind,
24 updateMaskOf,
25 waitUntilGone,
26} from "./internal.ts";
27
28export type AppsToolsetProps = {
29 /**
30 * Parent CES app. Full name
31 * `projects/{project}/locations/{location}/apps/{app}` or the app id
32 * (combined with `location`). Immutable — changing it replaces the
33 * toolset.
34 */
35 app: string;
36 /**
37 * Region used when `app` is a bare id.
38 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
39 */
40 location?: string;
41 /**
42 * Toolset id. If omitted, a unique name is generated. Immutable —
43 * changing it replaces the toolset.
44 */
45 toolsetId?: string;
46 /**
47 * Human-readable name. Must be unique within the app. Alchemy falls
48 * back to the generated toolset id.
49 */
50 displayName?: string;
51 /**
52 * Human-readable description.
53 */
54 description?: string;
55 /**
56 * Execution type for tools in the toolset.
57 */
58 executionType?: string;
59 /**
60 * Fake-mode configuration.
61 */
62 toolFakeConfig?: ces.ToolFakeConfig;
63 /**
64 * OpenAPI toolset. Used when no other toolset variant is set.
65 */
66 openApiToolset?: ces.OpenApiToolset;
67 /** MCP toolset. */
68 mcpToolset?: ces.McpToolset;
69 /** Integration Connectors toolset. */
70 connectorToolset?: ces.ConnectorToolset;
71};
72
73export type AppsToolset = Resource<
74 "GCP.CES.AppsToolset",
75 AppsToolsetProps,
76 {
77 /** Full resource name `.../apps/{app}/toolsets/{toolset}`. */
78 name: string;
79 /** Toolset id (last path segment). */
80 toolsetId: string;
81 /** Parent app resource name. */
82 app: string;
83 /** Location id. */
84 location: string;
85 /** Project id. */
86 project: string;
87 /** Display name. */
88 displayName: string | undefined;
89 /** User description with the Alchemy ownership prefix stripped. */
90 description: string | undefined;
91 /** Execution type. */
92 executionType: string | undefined;
93 /** Fake-mode configuration. */
94 toolFakeConfig: ces.ToolFakeConfig | undefined;
95 /** OpenAPI toolset. */
96 openApiToolset: ces.OpenApiToolset | undefined;
97 /** MCP toolset. */
98 mcpToolset: ces.McpToolset | undefined;
99 /** Connector toolset. */
100 connectorToolset: ces.ConnectorToolset | undefined;
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 /** Server-assigned etag. */
106 etag: string | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A Customer Engagement Suite toolset — a dynamically managed group of
114 * tools defined by OpenAPI, MCP, or Integration Connectors.
115 *
116 * Toolsets have no labels field, and the description reaches the model,
117 * so Alchemy writes no ownership marker: a generated toolset id
118 * identifies the toolset, and `read` reports an explicit id it has no
119 * state for as unowned. Parent app, location, toolset id, and toolset
120 * kind are immutable.
121 *
122 * ### Creating a Toolset
123 * **Example:** OpenAPI ping toolset
124 * ```typescript
125 * const toolset = yield* GCP.CES.AppsToolset("Ping", {
126 * app: app.name,
127 * displayName: "ping",
128 * openApiToolset: {
129 * openApiSchema: JSON.stringify({
130 * openapi: "3.0.0",
131 * info: { title: "Ping", version: "1.0.0" },
132 * paths: {
133 * "/ping": {
134 * get: {
135 * operationId: "ping",
136 * responses: { "200": { description: "ok" } },
137 * },
138 * },
139 * },
140 * }),
141 * },
142 * });
143 * ```
144 *
145 * @resource
146 * @category CES
147 */
148export const AppsToolset = Resource<AppsToolset>("GCP.CES.AppsToolset");
149
150export class AppsToolsetNotResolved extends Data.TaggedError(
151 "GCP.CES.AppsToolsetNotResolved",
152)<{
153 name: string;
154}> {}
155
156const resourceName = (app: string, toolsetId: string) =>
157 `${app}/toolsets/${toolsetId}`;
158
159const openApiOf = (news: AppsToolsetProps): ces.OpenApiToolset | undefined => {
160 if (news.mcpToolset || news.connectorToolset) return news.openApiToolset;
161 return (
162 news.openApiToolset ?? {
163 openApiSchema: DEFAULT_OPENAPI_SCHEMA,
164 }
165 );
166};
167
168const toAttrs = (
169 toolset: ces.Toolset,
170 project: string,
171 region: string,
172 appHint?: string,
173) => {
174 const name = toolset.name ?? "";
175 const parsed = parseResourceName(name, "toolsets", region);
176 return {
177 name,
178 toolsetId: parsed.id,
179 app: name.includes("/toolsets/") ? parsed.app : (appHint ?? parsed.parent),
180 location: parsed.location,
181 project: parsed.project || project,
182 displayName: toolset.displayName,
183 description: parseOwnership(toolset.description).text,
184 executionType: toolset.executionType,
185 toolFakeConfig: toolset.toolFakeConfig,
186 openApiToolset: toolset.openApiToolset,
187 mcpToolset: toolset.mcpToolset,
188 connectorToolset: toolset.connectorToolset,
189 createTime: toolset.createTime,
190 updateTime: toolset.updateTime,
191 etag: toolset.etag,
192 };
193};
194
195const getByName = (name: string) =>
196 name.length === 0
197 ? Effect.succeed(undefined)
198 : ces
199 .getProjectsLocationsAppsToolsets({ name })
200 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
201
202const listAt = (parent: string, project: string, region: string) =>
203 collectPages(
204 ces.listProjectsLocationsAppsToolsets.pages({ parent, pageSize: 100 }),
205 (page) => page.toolsets,
206 ).pipe(
207 Effect.map((toolsets) =>
208 toolsets.map((toolset) => toAttrs(toolset, project, region, parent)),
209 ),
210 );
211
212export const AppsToolsetProvider = () =>
213 Provider.succeed(AppsToolset, {
214 stables: ["name", "toolsetId", "app", "location", "project", "createTime"],
215
216 diff: Effect.fn(function* ({ news, olds, output }) {
217 if (!isResolved(news)) return undefined;
218 const previousKind = toolsetKind(olds ?? output ?? {});
219 const nextKind = toolsetKind(news);
220 return replaceOnIdentity({
221 previousId: olds?.toolsetId ?? output?.toolsetId,
222 nextId: news.toolsetId,
223 previousParent: olds?.app ?? output?.app,
224 nextParent: news.app,
225 extra: previousKind !== nextKind && previousKind !== "openApiToolset",
226 });
227 }),
228
229 read: Effect.fn(function* ({ id, olds, output }) {
230 const env = yield* GcpEnvironment.current;
231 const location = normalizeLocation(
232 olds?.location ?? output?.location,
233 env.region,
234 );
235 const app = olds?.app
236 ? expandApp(olds.app, env.project, location)
237 : output?.app;
238 const toolsetId = yield* toPhysicalId(
239 id,
240 olds?.toolsetId,
241 output?.toolsetId,
242 );
243 const name =
244 output?.name ?? (app !== undefined ? resourceName(app, toolsetId) : "");
245 const existing = yield* getByName(name);
246 if (existing === undefined) return undefined;
247 const attrs = toAttrs(existing, env.project, env.region, app);
248 return output !== undefined || olds?.toolsetId === undefined
249 ? attrs
250 : Unowned(attrs);
251 }),
252
253 list: () =>
254 Effect.gen(function* () {
255 const env = yield* GcpEnvironment.current;
256 return yield* forEachApp(
257 env.project,
258 (parent) => listAt(parent, env.project, env.region),
259 env.region,
260 );
261 }),
262
263 reconcile: Effect.fn(function* ({ id, news, output }) {
264 const env = yield* GcpEnvironment.current;
265 const location = normalizeLocation(
266 news.location ?? output?.location,
267 env.region,
268 );
269 const app = expandApp(news.app, env.project, location);
270 const toolsetId = yield* toPhysicalId(
271 id,
272 news.toolsetId,
273 output?.toolsetId,
274 );
275 const name = output?.name ?? resourceName(app, toolsetId);
276 const description = news.description;
277 const displayName = news.displayName ?? toolsetId;
278 const openApiToolset = openApiOf(news);
279 const kind = toolsetKind({
280 openApiToolset,
281 mcpToolset: news.mcpToolset,
282 connectorToolset: news.connectorToolset,
283 });
284
285 let current = yield* getByName(name);
286
287 if (current === undefined) {
288 const created = yield* retryTransient(
289 ces.createProjectsLocationsAppsToolsets({
290 parent: app,
291 toolsetId,
292 body: {
293 displayName,
294 description,
295 executionType: news.executionType,
296 toolFakeConfig: news.toolFakeConfig,
297 openApiToolset,
298 mcpToolset: news.mcpToolset,
299 connectorToolset: news.connectorToolset,
300 },
301 }),
302 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
303 current = created ?? undefined;
304 }
305
306 if (current === undefined) {
307 return yield* new AppsToolsetNotResolved({ name });
308 }
309
310 const currentName = current.name ?? name;
311 const displayChanged = !sameText(current.displayName, displayName);
312 const descriptionChanged = !sameText(current.description, description);
313 const executionChanged = !sameText(
314 current.executionType,
315 news.executionType,
316 );
317 const fakeChanged = !sameJson(
318 current.toolFakeConfig,
319 news.toolFakeConfig,
320 );
321 const variantChanged =
322 kind === "openApiToolset"
323 ? !sameJson(current.openApiToolset, openApiToolset)
324 : kind === "mcpToolset"
325 ? !sameJson(current.mcpToolset, news.mcpToolset)
326 : !sameJson(current.connectorToolset, news.connectorToolset);
327
328 if (
329 displayChanged ||
330 descriptionChanged ||
331 executionChanged ||
332 fakeChanged ||
333 variantChanged
334 ) {
335 current = yield* retryTransient(
336 ces.patchProjectsLocationsAppsToolsets({
337 name: currentName,
338 updateMask: updateMaskOf(
339 displayChanged ? "display_name" : undefined,
340 descriptionChanged ? "description" : undefined,
341 executionChanged ? "execution_type" : undefined,
342 fakeChanged ? "tool_fake_config" : undefined,
343 variantChanged
344 ? kind === "openApiToolset"
345 ? "open_api_toolset"
346 : kind === "mcpToolset"
347 ? "mcp_toolset"
348 : "connector_toolset"
349 : undefined,
350 ),
351 body: {
352 displayName,
353 description,
354 executionType: news.executionType,
355 toolFakeConfig: news.toolFakeConfig,
356 openApiToolset,
357 mcpToolset: news.mcpToolset,
358 connectorToolset: news.connectorToolset,
359 },
360 }),
361 );
362 }
363
364 return toAttrs(current, env.project, env.region, app);
365 }),
366
367 delete: Effect.fn(function* ({ output }) {
368 if (!output.name) return;
369 yield* retryTransient(
370 ces.deleteProjectsLocationsAppsToolsets({
371 name: output.name,
372 force: true,
373 }),
374 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
375 yield* waitUntilGone(getByName(output.name));
376 }),
377 });