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 { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeOwnership,
13 hasAlchemyLabelMap,
14 hasOwnershipMarker,
15 listApps,
16 locationParent,
17 normalizeLocation,
18 ownedByAlchemy,
19 ownedByAlchemyLabels,
20 parseOwnership,
21 parseResourceName,
22 replaceOnIdentity,
23 retryTransient,
24 sameJson,
25 sameText,
26 toPhysicalId,
27 updateMaskOf,
28 userMetadata,
29 waitForVisible,
30 waitUntilGone,
31} from "./internal.ts";
32import { waitForDeleteOperation, waitForOperation } from "./operations.ts";
33
34export type AppProps = {
35 /**
36 * App id (the `{app}` segment of
37 * `projects/{project}/locations/{location}/apps/{app}`). If omitted, a
38 * unique name is generated. Immutable — changing it replaces the app.
39 */
40 appId?: string;
41 /**
42 * Location (`us-central1`, …). Immutable — changing it replaces the
43 * app. `US-CENTRAL1` is accepted and normalized to `us-central1`.
44 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
45 */
46 location?: string;
47 /**
48 * Human-readable name. Required by the API; Alchemy falls back to the
49 * generated app id.
50 */
51 displayName?: string;
52 /**
53 * Human-readable description. Apps have no labels field, so Alchemy
54 * stamps ownership into `description` and `metadata`.
55 */
56 description?: string;
57 /**
58 * Free-form metadata. Alchemy ownership keys (`alchemy-stack`,
59 * `alchemy-stage`, `alchemy-id`) are merged in automatically.
60 */
61 metadata?: Record<string, string>;
62 /**
63 * Instructions shared by every agent in the app.
64 */
65 globalInstruction?: string;
66 /**
67 * Tool execution mode (`PARALLEL`, `SEQUENTIAL`).
68 */
69 toolExecutionMode?: string;
70 /**
71 * IANA time zone (e.g. `America/Los_Angeles`). Maps onto
72 * `timeZoneSettings.timeZone`.
73 */
74 timeZone?: string;
75 /**
76 * Full time-zone settings. Wins over `timeZone` when both are set.
77 */
78 timeZoneSettings?: ces.TimeZoneSettings;
79 /**
80 * Default LLM model id.
81 */
82 model?: string;
83 /**
84 * Default LLM temperature.
85 */
86 temperature?: number;
87 /**
88 * Full model settings. Wins over `model` / `temperature` when set.
89 */
90 modelSettings?: ces.ModelSettings;
91 /**
92 * Root agent resource name
93 * `projects/{project}/locations/{location}/apps/{app}/agents/{agent}`.
94 */
95 rootAgent?: string;
96 /**
97 * Guardrail resource names attached to the app.
98 */
99 guardrails?: string[];
100 /**
101 * When true, mutations to app resources are rejected.
102 * @default false
103 */
104 locked?: boolean;
105 /**
106 * When true, the app is pinned in the app list.
107 * @default false
108 */
109 pinned?: boolean;
110 /**
111 * Default channel profile for the app.
112 */
113 defaultChannelProfile?: ces.ChannelProfile;
114 /**
115 * Language settings.
116 */
117 languageSettings?: ces.LanguageSettings;
118};
119
120export type App = Resource<
121 "GCP.CES.App",
122 AppProps,
123 {
124 /** Full resource name `projects/{project}/locations/{location}/apps/{app}`. */
125 name: string;
126 /** App id (last path segment). */
127 appId: string;
128 /** Location id. */
129 location: string;
130 /** Project id. */
131 project: string;
132 /** Display name. */
133 displayName: string | undefined;
134 /** User description with the Alchemy ownership prefix stripped. */
135 description: string | undefined;
136 /** User metadata (Alchemy ownership keys stripped). */
137 metadata: Record<string, string>;
138 /** Shared agent instructions. */
139 globalInstruction: string | undefined;
140 /** Tool execution mode. */
141 toolExecutionMode: string | undefined;
142 /** IANA time zone. */
143 timeZone: string | undefined;
144 /** Default model settings. */
145 modelSettings: ces.ModelSettings | undefined;
146 /** Root agent resource name. */
147 rootAgent: string | undefined;
148 /** Guardrail resource names. */
149 guardrails: string[] | undefined;
150 /** Whether the app is locked. */
151 locked: boolean;
152 /** Whether the app is pinned. */
153 pinned: boolean;
154 /** Default channel profile. */
155 defaultChannelProfile: ces.ChannelProfile | undefined;
156 /** Language settings. */
157 languageSettings: ces.LanguageSettings | undefined;
158 /** RFC3339 creation timestamp. */
159 createTime: string | undefined;
160 /** RFC3339 last-update timestamp. */
161 updateTime: string | undefined;
162 /** Server-assigned etag. */
163 etag: string | undefined;
164 },
165 never,
166 Providers
167>;
168
169/**
170 * A Customer Engagement Suite (CES) app — the top-level container for
171 * agents, tools, toolsets, guardrails, examples, versions, and
172 * deployments.
173 *
174 * App create and delete are long-running operations. Location and app id
175 * are immutable. CES apps have no labels API, so Alchemy stamps
176 * ownership into `metadata` and `description` so `list` / nuke can find
177 * them.
178 *
179 * ### Creating an App
180 * **Example:** Generated name
181 * ```typescript
182 * const app = yield* GCP.CES.App("Support", {
183 * displayName: "support",
184 * });
185 * ```
186 *
187 * **Example:** Named app with a time zone and model
188 * ```typescript
189 * const app = yield* GCP.CES.App("Support", {
190 * appId: "support-desk",
191 * location: "us-central1",
192 * displayName: "Support desk",
193 * timeZone: "America/Los_Angeles",
194 * model: "gemini-2.0-flash",
195 * });
196 * ```
197 *
198 * @resource
199 * @category CES
200 */
201export const App = Resource<App>("GCP.CES.App");
202
203export class AppNotResolved extends Data.TaggedError("GCP.CES.AppNotResolved")<{
204 name: string;
205}> {}
206
207const resourceName = (project: string, location: string, appId: string) =>
208 `${locationParent(project, location)}/apps/${appId}`;
209
210const timeZoneSettingsOf = (news: AppProps): ces.TimeZoneSettings | undefined =>
211 news.timeZoneSettings ??
212 (news.timeZone !== undefined ? { timeZone: news.timeZone } : undefined);
213
214const modelSettingsOf = (news: AppProps): ces.ModelSettings | undefined =>
215 news.modelSettings ??
216 (news.model !== undefined || news.temperature !== undefined
217 ? { model: news.model, temperature: news.temperature }
218 : undefined);
219
220const toAttrs = (app: ces.App, project: string, region: string) => {
221 const name = app.name ?? "";
222 const parsed = parseResourceName(name, "apps", region);
223 return {
224 name,
225 appId: parsed.id,
226 location: parsed.location,
227 project: parsed.project || project,
228 displayName: app.displayName,
229 description: parseOwnership(app.description).text,
230 metadata: userMetadata(app.metadata),
231 globalInstruction: app.globalInstruction,
232 toolExecutionMode: app.toolExecutionMode,
233 timeZone: app.timeZoneSettings?.timeZone,
234 modelSettings: app.modelSettings,
235 rootAgent: app.rootAgent,
236 guardrails: app.guardrails,
237 locked: app.locked === true,
238 pinned: app.pinned === true,
239 defaultChannelProfile: app.defaultChannelProfile,
240 languageSettings: app.languageSettings,
241 createTime: app.createTime,
242 updateTime: app.updateTime,
243 etag: app.etag,
244 };
245};
246
247const getByName = (name: string) =>
248 name.length === 0
249 ? Effect.succeed(undefined)
250 : ces
251 .getProjectsLocationsApps({ name })
252 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
253
254const isOwnedApp = (app: ces.App) =>
255 hasAlchemyLabelMap(app.metadata) ||
256 hasOwnershipMarker(app.description) ||
257 hasOwnershipMarker(app.displayName);
258
259export const AppProvider = () =>
260 Provider.succeed(App, {
261 stables: ["name", "appId", "location", "project", "createTime"],
262
263 diff: Effect.fn(function* ({ news, olds, output }) {
264 if (!isResolved(news)) return undefined;
265 const env = yield* GcpEnvironment.current;
266 const previousLocation = olds?.location ?? output?.location;
267 const nextLocation = normalizeLocation(news.location, env.region);
268 if (
269 previousLocation !== undefined &&
270 news.location !== undefined &&
271 normalizeLocation(previousLocation, env.region) !== nextLocation
272 ) {
273 return { action: "replace" as const, deleteFirst: false };
274 }
275 return replaceOnIdentity({
276 previousId: olds?.appId ?? output?.appId,
277 nextId: news.appId,
278 });
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const env = yield* GcpEnvironment.current;
283 const location = normalizeLocation(
284 olds?.location ?? output?.location,
285 env.region,
286 );
287 const appId =
288 olds?.appId ??
289 output?.appId ??
290 (output?.name
291 ? parseResourceName(output.name, "apps", env.region).id
292 : "");
293 const name =
294 output?.name ??
295 (appId.length > 0 ? resourceName(env.project, location, appId) : "");
296 const existing = yield* getByName(name);
297 if (existing === undefined) return undefined;
298 const attrs = toAttrs(existing, env.project, env.region);
299 const labeled = yield* ownedByAlchemyLabels(id, existing.metadata);
300 const described = yield* ownedByAlchemy(id, existing.description);
301 return labeled || described ? attrs : Unowned(attrs);
302 }),
303
304 list: () =>
305 Effect.gen(function* () {
306 const env = yield* GcpEnvironment.current;
307 const apps = yield* listApps(locationParent(env.project, env.region));
308 return apps
309 .filter(isOwnedApp)
310 .map((app) => toAttrs(app, env.project, env.region));
311 }),
312
313 reconcile: Effect.fn(function* ({ id, news, output }) {
314 const env = yield* GcpEnvironment.current;
315 const location = normalizeLocation(
316 news.location ?? output?.location,
317 env.region,
318 );
319 const appId = yield* toPhysicalId(id, news.appId, output?.appId);
320 const name = output?.name ?? resourceName(env.project, location, appId);
321 const parent = locationParent(env.project, location);
322 const ownership = yield* createInternalLabels(id);
323 const description = encodeOwnership(ownership, news.description);
324 const metadata = { ...news.metadata, ...ownership };
325 const displayName = news.displayName ?? appId;
326 const timeZoneSettings = timeZoneSettingsOf(news);
327 const modelSettings = modelSettingsOf(news);
328
329 let current = yield* getByName(name);
330
331 if (current === undefined) {
332 const created = yield* retryTransient(
333 ces.createProjectsLocationsApps({
334 parent,
335 appId,
336 body: {
337 displayName,
338 description,
339 metadata,
340 globalInstruction: news.globalInstruction,
341 toolExecutionMode: news.toolExecutionMode,
342 timeZoneSettings,
343 modelSettings,
344 rootAgent: news.rootAgent,
345 guardrails: news.guardrails,
346 locked: news.locked,
347 pinned: news.pinned,
348 defaultChannelProfile: news.defaultChannelProfile,
349 languageSettings: news.languageSettings,
350 },
351 }),
352 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
353 if (created !== undefined) {
354 yield* waitForOperation(created);
355 }
356 current = yield* waitForVisible(getByName(name));
357 }
358
359 if (current === undefined) {
360 return yield* new AppNotResolved({ name });
361 }
362
363 const currentName = current.name ?? name;
364 const displayChanged = !sameText(current.displayName, displayName);
365 const descriptionChanged = !sameText(current.description, description);
366 const metadataChanged = !sameJson(current.metadata, metadata);
367 const instructionChanged = !sameText(
368 current.globalInstruction,
369 news.globalInstruction,
370 );
371 const modeChanged = !sameText(
372 current.toolExecutionMode,
373 news.toolExecutionMode,
374 );
375 const zoneChanged = !sameJson(current.timeZoneSettings, timeZoneSettings);
376 const modelChanged = !sameJson(current.modelSettings, modelSettings);
377 const rootChanged = !sameText(current.rootAgent, news.rootAgent);
378 const guardrailsChanged = !sameJson(current.guardrails, news.guardrails);
379 const lockedChanged =
380 (current.locked === true) !== (news.locked === true);
381 const pinnedChanged =
382 (current.pinned === true) !== (news.pinned === true);
383 const channelChanged = !sameJson(
384 current.defaultChannelProfile,
385 news.defaultChannelProfile,
386 );
387 const languageChanged = !sameJson(
388 current.languageSettings,
389 news.languageSettings,
390 );
391
392 if (
393 displayChanged ||
394 descriptionChanged ||
395 metadataChanged ||
396 instructionChanged ||
397 modeChanged ||
398 zoneChanged ||
399 modelChanged ||
400 rootChanged ||
401 guardrailsChanged ||
402 lockedChanged ||
403 pinnedChanged ||
404 channelChanged ||
405 languageChanged
406 ) {
407 current = yield* retryTransient(
408 ces.patchProjectsLocationsApps({
409 name: currentName,
410 updateMask: updateMaskOf(
411 displayChanged ? "display_name" : undefined,
412 descriptionChanged ? "description" : undefined,
413 metadataChanged ? "metadata" : undefined,
414 instructionChanged ? "global_instruction" : undefined,
415 modeChanged ? "tool_execution_mode" : undefined,
416 zoneChanged ? "time_zone_settings" : undefined,
417 modelChanged ? "model_settings" : undefined,
418 rootChanged ? "root_agent" : undefined,
419 guardrailsChanged ? "guardrails" : undefined,
420 lockedChanged ? "locked" : undefined,
421 pinnedChanged ? "pinned" : undefined,
422 channelChanged ? "default_channel_profile" : undefined,
423 languageChanged ? "language_settings" : undefined,
424 ),
425 body: {
426 displayName,
427 description,
428 metadata,
429 globalInstruction: news.globalInstruction,
430 toolExecutionMode: news.toolExecutionMode,
431 timeZoneSettings,
432 modelSettings,
433 rootAgent: news.rootAgent,
434 guardrails: news.guardrails,
435 locked: news.locked,
436 pinned: news.pinned,
437 defaultChannelProfile: news.defaultChannelProfile,
438 languageSettings: news.languageSettings,
439 },
440 }),
441 );
442 }
443
444 return toAttrs(current, env.project, env.region);
445 }),
446
447 delete: Effect.fn(function* ({ output }) {
448 if (!output.name) return;
449 const deleted = yield* retryTransient(
450 ces.deleteProjectsLocationsApps({ name: output.name }),
451 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
452 if (deleted !== undefined) {
453 yield* waitForDeleteOperation(deleted);
454 }
455 yield* waitUntilGone(getByName(output.name));
456 }),
457 });