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 listAgents,
13 listEnvironments,
14 listExperiments,
15 ownedByAlchemy,
16 ownershipLabels,
17 ownershipText,
18 parseOwnership,
19 parseResourceName,
20 sameJson,
21 sameText,
22 updateMaskOf,
23 retryQuota,
24} from "./internal.ts";
25
26export type ExperimentVariant = {
27 /** Flow version resource name. */
28 version: string;
29 /** Traffic fraction in `[0, 1]`. */
30 trafficAllocation?: number;
31 /** Whether this variant is the control group. */
32 isControlGroup?: boolean;
33};
34
35export type ExperimentDefinition = {
36 /** Condition selecting sessions that enter the experiment. */
37 condition?: string;
38 /** Version variants and traffic split. */
39 versionVariants?: {
40 variants?: ExperimentVariant[];
41 };
42};
43
44export type AgentsEnvironmentsExperimentProps = {
45 /**
46 * Parent environment resource name
47 * `projects/{project}/locations/{location}/agents/{agent}/environments/{environment}`.
48 * Immutable — changing it replaces the experiment.
49 */
50 environment: string;
51 /**
52 * Experiment id. Server-assigned on create. Immutable — changing it
53 * replaces the experiment.
54 */
55 experimentId?: string;
56 /** Human-readable name, unique within the environment. */
57 displayName?: string;
58 /**
59 * Description. Experiments have no labels field, so Alchemy stamps
60 * ownership into this field for `list` / nuke.
61 */
62 description?: string;
63 /** Experiment definition (condition and version variants). */
64 definition?: ExperimentDefinition;
65 /** Experiment duration (e.g. `"86400s"`). */
66 experimentLength?: string;
67};
68
69export type AgentsEnvironmentsExperiment = Resource<
70 "GCP.Dialogflow.AgentsEnvironmentsExperiment",
71 AgentsEnvironmentsExperimentProps,
72 {
73 /** Full resource name. */
74 name: string;
75 /** Experiment id (last path segment). */
76 experimentId: string;
77 /** Parent environment resource name. */
78 environment: string;
79 /** Project id. */
80 project: string;
81 /** Location id. */
82 location: string;
83 /** Display name. */
84 displayName: string | undefined;
85 /** User description with the Alchemy ownership prefix stripped. */
86 description: string | undefined;
87 /** Experiment definition. */
88 definition: ExperimentDefinition | undefined;
89 /** Experiment duration. */
90 experimentLength: string | undefined;
91 /** Experiment state. */
92 state: string | undefined;
93 /** RFC3339 creation timestamp. */
94 createTime: string | undefined;
95 },
96 never,
97 Providers
98>;
99
100/**
101 * A Dialogflow CX experiment under an environment.
102 *
103 * Experiments have no labels field, so Alchemy stamps ownership into
104 * `description` for `list` / nuke. Parent environment and experiment id
105 * are immutable. Display name, description, definition, and length
106 * update in place.
107 *
108 * ### Creating an Experiment
109 * **Example:** A/B test two flow versions
110 * ```typescript
111 * const experiment = yield* GCP.Dialogflow.AgentsEnvironmentsExperiment(
112 * "Ab",
113 * {
114 * environment: environment.name,
115 * displayName: "checkout-ab",
116 * definition: {
117 * versionVariants: {
118 * variants: [
119 * {
120 * version: control.name,
121 * trafficAllocation: 0.5,
122 * isControlGroup: true,
123 * },
124 * { version: treatment.name, trafficAllocation: 0.5 },
125 * ],
126 * },
127 * },
128 * },
129 * );
130 * ```
131 *
132 * @resource
133 * @category Dialogflow
134 */
135export const AgentsEnvironmentsExperiment =
136 Resource<AgentsEnvironmentsExperiment>(
137 "GCP.Dialogflow.AgentsEnvironmentsExperiment",
138 );
139
140export class AgentsEnvironmentsExperimentNotResolved extends Data.TaggedError(
141 "GCP.Dialogflow.AgentsEnvironmentsExperimentNotResolved",
142)<{
143 name: string;
144}> {}
145
146const definitionOf = (
147 definition:
148 | dialogflow.GoogleCloudDialogflowCxV3ExperimentDefinition
149 | undefined,
150): ExperimentDefinition | undefined => {
151 if (definition === undefined) return undefined;
152 return {
153 condition: definition.condition,
154 versionVariants: definition.versionVariants
155 ? {
156 variants: (definition.versionVariants.variants ?? []).map(
157 (variant) => ({
158 version: variant.version ?? "",
159 trafficAllocation: variant.trafficAllocation,
160 isControlGroup: variant.isControlGroup,
161 }),
162 ),
163 }
164 : undefined,
165 };
166};
167
168const toAttrs = (
169 experiment: dialogflow.GoogleCloudDialogflowCxV3Experiment,
170 project: string,
171) => {
172 const name = experiment.name ?? "";
173 const parsed = parseResourceName(name, "experiments");
174 return {
175 name,
176 experimentId: parsed.id,
177 environment: parsed.environment || parsed.parent,
178 project: parsed.project || project,
179 location: parsed.location,
180 displayName: experiment.displayName,
181 description: parseOwnership(experiment.description).text,
182 definition: definitionOf(experiment.definition),
183 experimentLength: experiment.experimentLength,
184 state: experiment.state,
185 createTime: experiment.createTime,
186 };
187};
188
189const getByName = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : dialogflow
193 .getProjectsLocationsAgentsEnvironmentsExperiments({ name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196const findOwned = (id: string, environment: string, hinted?: string) =>
197 Effect.gen(function* () {
198 if (hinted !== undefined && hinted.length > 0) {
199 const existing = yield* getByName(hinted);
200 if (existing !== undefined) return existing;
201 }
202 const experiments = yield* listExperiments(environment);
203 for (const experiment of experiments) {
204 if (yield* ownedByAlchemy(id, ownershipText(experiment))) {
205 return experiment;
206 }
207 }
208 return undefined as
209 | dialogflow.GoogleCloudDialogflowCxV3Experiment
210 | undefined;
211 });
212
213const listOwned = (project: string) =>
214 Effect.gen(function* () {
215 const agents = yield* listAgents(project);
216 const environments = (yield* Effect.forEach(
217 agents,
218 (agent) =>
219 agent.name ? listEnvironments(agent.name) : Effect.succeed([]),
220 { concurrency: 4 },
221 )).flat();
222 const experiments = (yield* Effect.forEach(
223 environments,
224 (environment) =>
225 environment.name
226 ? listExperiments(environment.name)
227 : Effect.succeed([]),
228 { concurrency: 4 },
229 )).flat();
230 return experiments
231 .filter(
232 (experiment) =>
233 parseOwnership(experiment.description).labels["alchemy-id"] !==
234 undefined ||
235 parseOwnership(experiment.displayName).labels["alchemy-id"] !==
236 undefined,
237 )
238 .map((experiment) => toAttrs(experiment, project));
239 });
240
241export const AgentsEnvironmentsExperimentProvider = () =>
242 Provider.succeed(AgentsEnvironmentsExperiment, {
243 stables: [
244 "name",
245 "experimentId",
246 "environment",
247 "project",
248 "location",
249 "createTime",
250 ],
251
252 diff: Effect.fn(function* ({ news, olds, output }) {
253 if (!isResolved(news)) return undefined;
254 const previousParent = olds?.environment ?? output?.environment;
255 const previousId = olds?.experimentId ?? output?.experimentId;
256 if (
257 (previousParent !== undefined && news.environment !== previousParent) ||
258 (previousId !== undefined &&
259 news.experimentId !== undefined &&
260 news.experimentId !== previousId)
261 ) {
262 return {
263 action: "replace" as const,
264 deleteFirst:
265 previousParent === news.environment &&
266 previousId !== undefined &&
267 news.experimentId === previousId,
268 };
269 }
270 return undefined;
271 }),
272
273 read: Effect.fn(function* ({ id, olds, output }) {
274 const env = yield* GcpEnvironment.current;
275 const environment = olds?.environment ?? output?.environment;
276 const existing =
277 output?.name !== undefined
278 ? yield* getByName(output.name)
279 : environment !== undefined
280 ? yield* findOwned(id, environment)
281 : undefined;
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, env.project);
284 return (yield* ownedByAlchemy(id, ownershipText(existing)))
285 ? attrs
286 : Unowned(attrs);
287 }),
288
289 list: () =>
290 Effect.gen(function* () {
291 const env = yield* GcpEnvironment.current;
292 return yield* listOwned(env.project);
293 }),
294
295 reconcile: Effect.fn(function* ({ id, news, output }) {
296 const env = yield* GcpEnvironment.current;
297 const environment = news.environment;
298 const ownership = yield* ownershipLabels(id);
299 const description = encodeOwnership(ownership, news.description);
300 const displayName = news.displayName ?? "experiment";
301 const body: dialogflow.GoogleCloudDialogflowCxV3Experiment = {
302 displayName,
303 description,
304 definition: news.definition,
305 experimentLength: news.experimentLength,
306 };
307
308 let current = yield* findOwned(id, environment, output?.name);
309
310 if (current === undefined) {
311 const created = yield* dialogflow
312 .createProjectsLocationsAgentsEnvironmentsExperiments({
313 parent: environment,
314 body,
315 })
316 .pipe(
317 Effect.catchTag("Conflict", () =>
318 findOwned(id, environment, output?.name),
319 ),
320 );
321 current = created ?? undefined;
322 }
323
324 if (current === undefined) {
325 return yield* new AgentsEnvironmentsExperimentNotResolved({
326 name:
327 output?.name ??
328 `${environment}/experiments/${news.experimentId ?? "unknown"}`,
329 });
330 }
331
332 const currentName = current.name ?? output?.name ?? "";
333 const displayChanged = !sameText(current.displayName, displayName);
334 const descriptionChanged = !sameText(current.description, description);
335 const definitionChanged = !sameJson(
336 definitionOf(current.definition),
337 news.definition,
338 );
339 const lengthChanged = !sameText(
340 current.experimentLength,
341 news.experimentLength,
342 );
343
344 if (
345 displayChanged ||
346 descriptionChanged ||
347 definitionChanged ||
348 lengthChanged
349 ) {
350 current =
351 yield* dialogflow.patchProjectsLocationsAgentsEnvironmentsExperiments(
352 {
353 name: currentName,
354 updateMask: updateMaskOf(
355 displayChanged ? "display_name" : undefined,
356 descriptionChanged ? "description" : undefined,
357 definitionChanged ? "definition" : undefined,
358 lengthChanged ? "experiment_length" : undefined,
359 ),
360 body: { ...body, name: currentName },
361 },
362 );
363 }
364
365 return toAttrs(current, env.project);
366 }, retryQuota),
367
368 delete: Effect.fn(function* ({ output }) {
369 yield* dialogflow
370 .deleteProjectsLocationsAgentsEnvironmentsExperiments({
371 name: output.name,
372 })
373 .pipe(Effect.catchTag("NotFound", () => Effect.void));
374 }, retryQuota),
375 });