1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
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 encodeOwnership,
12 expandName,
13 listAgents,
14 listEnvironments,
15 normalizeLocation,
16 ownedByAlchemy,
17 ownershipLabels,
18 ownershipText,
19 parseOwnership,
20 parseResourceName,
21 resourceNameFromOperation,
22 sameJson,
23 sameText,
24 updateMaskOf,
25 waitForOperation,
26 retryQuota,
27} from "./internal.ts";
28
29export type EnvironmentVersionConfig = {
30 /**
31 * Flow version resource name
32 * `projects/{project}/locations/{location}/agents/{agent}/flows/{flow}/versions/{version}`.
33 */
34 version: string;
35};
36
37export type AgentsEnvironmentProps = {
38 /**
39 * Parent agent resource name
40 * `projects/{project}/locations/{location}/agents/{agent}`. Immutable —
41 * changing it replaces the environment.
42 */
43 agent: string;
44 /**
45 * Environment id (the `{environment}` segment). Server-assigned on
46 * create. Immutable — changing it replaces the environment.
47 */
48 environmentId?: string;
49 /**
50 * Location used when `agent` is a bare id.
51 * @default "global"
52 */
53 location?: string;
54 /** Human-readable name, unique within the agent. */
55 displayName?: string;
56 /**
57 * Description. Environments have no labels field, so Alchemy stamps
58 * ownership into this field for `list` / nuke.
59 */
60 description?: string;
61 /**
62 * Flow versions served by this environment. Must include a version of
63 * every flow reachable from the start flow.
64 */
65 versionConfigs: EnvironmentVersionConfig[];
66};
67
68export type AgentsEnvironment = Resource<
69 "GCP.Dialogflow.AgentsEnvironment",
70 AgentsEnvironmentProps,
71 {
72 /** Full resource name. */
73 name: string;
74 /** Environment id (last path segment). */
75 environmentId: string;
76 /** Parent agent resource name. */
77 agent: string;
78 /** Project id. */
79 project: string;
80 /** Location id. */
81 location: string;
82 /** Display name. */
83 displayName: string | undefined;
84 /** User description with the Alchemy ownership prefix stripped. */
85 description: string | undefined;
86 /** Served flow versions. */
87 versionConfigs: EnvironmentVersionConfig[];
88 /** RFC3339 last-update timestamp. */
89 updateTime: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A Dialogflow CX environment under an agent.
97 *
98 * Environments have no labels field, so Alchemy stamps ownership into
99 * `description` for `list` / nuke. Parent agent and environment id are
100 * immutable. Display name, description, and version configs update in
101 * place. Create and update are long-running operations.
102 *
103 * ### Creating an Environment
104 * **Example:** Serve a flow version
105 * ```typescript
106 * const version = yield* GCP.Dialogflow.AgentsFlowsVersion("V1", {
107 * flow: agent.startFlow,
108 * displayName: "v1",
109 * });
110 * const environment = yield* GCP.Dialogflow.AgentsEnvironment("Prod", {
111 * agent: agent.name,
112 * displayName: "prod",
113 * versionConfigs: [{ version: version.name }],
114 * });
115 * ```
116 *
117 * @resource
118 * @category Dialogflow
119 */
120export const AgentsEnvironment = Resource<AgentsEnvironment>(
121 "GCP.Dialogflow.AgentsEnvironment",
122);
123
124export class AgentsEnvironmentNotResolved extends Data.TaggedError(
125 "GCP.Dialogflow.AgentsEnvironmentNotResolved",
126)<{
127 name: string;
128}> {}
129
130const configsOf = (
131 list:
132 | readonly dialogflow.GoogleCloudDialogflowCxV3EnvironmentVersionConfig[]
133 | undefined,
134): EnvironmentVersionConfig[] =>
135 (list ?? [])
136 .filter((config) => (config.version ?? "").length > 0)
137 .map((config) => ({ version: config.version ?? "" }));
138
139const toAttrs = (
140 environment: dialogflow.GoogleCloudDialogflowCxV3Environment,
141 project: string,
142) => {
143 const name = environment.name ?? "";
144 const parsed = parseResourceName(name, "environments");
145 return {
146 name,
147 environmentId: parsed.id,
148 agent: parsed.agent,
149 project: parsed.project || project,
150 location: parsed.location,
151 displayName: environment.displayName,
152 description: parseOwnership(environment.description).text,
153 versionConfigs: configsOf(environment.versionConfigs),
154 updateTime: environment.updateTime,
155 };
156};
157
158const getByName = (name: string) =>
159 name.length === 0
160 ? Effect.succeed(undefined)
161 : dialogflow
162 .getProjectsLocationsAgentsEnvironments({ name })
163 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
164
165const findOwned = (id: string, agent: string, hinted?: string) =>
166 Effect.gen(function* () {
167 if (hinted !== undefined && hinted.length > 0) {
168 const existing = yield* getByName(hinted);
169 if (existing !== undefined) return existing;
170 }
171 const environments = yield* listEnvironments(agent);
172 for (const environment of environments) {
173 if (yield* ownedByAlchemy(id, ownershipText(environment))) {
174 return environment;
175 }
176 }
177 return undefined as
178 | dialogflow.GoogleCloudDialogflowCxV3Environment
179 | undefined;
180 });
181
182const resolveFromOperation = (
183 operation: dialogflow.GoogleLongrunningOperation,
184) =>
185 Effect.gen(function* () {
186 const done = yield* waitForOperation(operation);
187 const name =
188 resourceNameFromOperation(done) ?? resourceNameFromOperation(operation);
189 if (name === undefined) return undefined;
190 return yield* getByName(name);
191 });
192
193export const AgentsEnvironmentProvider = () =>
194 Provider.succeed(AgentsEnvironment, {
195 stables: ["name", "environmentId", "agent", "project", "location"],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 const previousAgent = olds?.agent ?? output?.agent;
200 const previousId = olds?.environmentId ?? output?.environmentId;
201 if (
202 (previousAgent !== undefined && news.agent !== previousAgent) ||
203 (previousId !== undefined &&
204 news.environmentId !== undefined &&
205 news.environmentId !== previousId)
206 ) {
207 return {
208 action: "replace" as const,
209 deleteFirst:
210 previousAgent === news.agent &&
211 previousId !== undefined &&
212 news.environmentId === previousId,
213 };
214 }
215 return undefined;
216 }),
217
218 read: Effect.fn(function* ({ id, olds, output }) {
219 const env = yield* GcpEnvironment.current;
220 const agent = olds?.agent ?? output?.agent;
221 const existing =
222 output?.name !== undefined
223 ? yield* getByName(output.name)
224 : agent !== undefined
225 ? yield* findOwned(id, agent)
226 : undefined;
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing, env.project);
229 return (yield* ownedByAlchemy(id, ownershipText(existing)))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: () =>
235 Effect.gen(function* () {
236 const env = yield* GcpEnvironment.current;
237 const agents = yield* listAgents(env.project);
238 const pages = yield* Effect.forEach(
239 agents,
240 (agent) =>
241 agent.name
242 ? listEnvironments(agent.name).pipe(
243 Effect.map((environments) =>
244 environments
245 .filter(
246 (environment) =>
247 parseOwnership(environment.description).labels[
248 "alchemy-id"
249 ] !== undefined ||
250 parseOwnership(environment.displayName).labels[
251 "alchemy-id"
252 ] !== undefined,
253 )
254 .map((environment) => toAttrs(environment, env.project)),
255 ),
256 )
257 : Effect.succeed([]),
258 { concurrency: 4 },
259 );
260 return pages.flat();
261 }),
262
263 reconcile: Effect.fn(function* ({ id, news, output }) {
264 const env = yield* GcpEnvironment.current;
265 const location = normalizeLocation(news.location ?? output?.location);
266 const agent = expandName(news.agent, env.project, location, "agents");
267 const ownership = yield* ownershipLabels(id);
268 const description = encodeOwnership(ownership, news.description);
269 const displayName = news.displayName ?? "environment";
270 const versionConfigs = news.versionConfigs;
271 const body: dialogflow.GoogleCloudDialogflowCxV3Environment = {
272 displayName,
273 description,
274 versionConfigs,
275 };
276
277 let current = yield* findOwned(id, agent, output?.name);
278
279 if (current === undefined) {
280 const operation = yield* dialogflow
281 .createProjectsLocationsAgentsEnvironments({
282 parent: agent,
283 body,
284 })
285 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
286 if (operation !== undefined) {
287 current = yield* resolveFromOperation(operation);
288 }
289 if (current === undefined) {
290 current = yield* findOwned(id, agent, output?.name);
291 }
292 }
293
294 if (current === undefined) {
295 return yield* new AgentsEnvironmentNotResolved({
296 name:
297 output?.name ??
298 `${agent}/environments/${news.environmentId ?? "unknown"}`,
299 });
300 }
301
302 const currentName = current.name ?? output?.name ?? "";
303 const displayChanged = !sameText(current.displayName, displayName);
304 const descriptionChanged = !sameText(current.description, description);
305 const versionsChanged = !sameJson(
306 configsOf(current.versionConfigs),
307 configsOf(versionConfigs),
308 );
309
310 if (displayChanged || descriptionChanged || versionsChanged) {
311 const operation =
312 yield* dialogflow.patchProjectsLocationsAgentsEnvironments({
313 name: currentName,
314 updateMask: updateMaskOf(
315 displayChanged ? "display_name" : undefined,
316 descriptionChanged ? "description" : undefined,
317 versionsChanged ? "version_configs" : undefined,
318 ),
319 body: { ...body, name: currentName },
320 });
321 const patched = yield* resolveFromOperation(operation);
322 current = patched ?? (yield* getByName(currentName)) ?? current;
323 }
324
325 return toAttrs(current, env.project);
326 }, retryQuota),
327
328 delete: Effect.fn(function* ({ output }) {
329 yield* dialogflow
330 .deleteProjectsLocationsAgentsEnvironments({ name: output.name })
331 .pipe(Effect.catchTag("NotFound", () => Effect.void));
332 }, retryQuota),
333 });