1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectionParent,
13 DEFAULT_LOCATION,
14 encodeOwnershipLine,
15 expandAgent,
16 fingerprint,
17 hasOwnershipMarker,
18 internalLabels,
19 lastSegment,
20 locationOf,
21 MAX_ROUTE_GROUP_DISPLAY_NAME_LENGTH,
22 namedAgents,
23 normalizeLocation,
24 ownedByAlchemy,
25 parentOf,
26 parseOwnership,
27 projectOf,
28 sameText,
29 toResourceId,
30 updateMaskOf,
31 retryQuota,
32} from "./internal.ts";
33
34type TransitionRoute = {
35 /** Intent that triggers this route. */
36 intent?: string;
37 /** Condition expression. At least one of `intent` or `condition` is required. */
38 condition?: string;
39 /** Target flow resource name. */
40 targetFlow?: string;
41 /** Target page resource name. */
42 targetPage?: string;
43 /** Description of the route (max 500 characters). */
44 description?: string;
45 /** Fulfillment run when the route matches. */
46 triggerFulfillment?: {
47 messages?: Array<{
48 text?: { text?: string[]; allowPlaybackInterruption?: boolean };
49 }>;
50 tag?: string;
51 };
52};
53
54export type AgentsTransitionRouteGroupProps = {
55 /**
56 * Parent agent resource name
57 * `projects/{project}/locations/{location}/agents/{agent}` or a bare
58 * agent id (combined with `location`). Immutable — changing it
59 * replaces the route group.
60 */
61 agent: string;
62 /**
63 * Transition route group id (the `{transitionRouteGroup}` segment).
64 * Server-assigned on create. Immutable — changing it replaces the
65 * group.
66 */
67 transitionRouteGroupId?: string;
68 /**
69 * Location used when `agent` is a bare id.
70 * @default "global"
71 */
72 location?: string;
73 /**
74 * Human-readable name, unique within the agent (max 30 characters
75 * including Alchemy's ownership marker). Route groups have no labels
76 * field, so ownership is stored in a compact `[alc …]` prefix and
77 * stripped from attributes.
78 */
79 displayName?: string;
80 /** Transition routes in the group. */
81 transitionRoutes?: TransitionRoute[];
82 /** Language code of the route group. */
83 languageCode?: string;
84};
85
86export type AgentsTransitionRouteGroup = Resource<
87 "GCP.Dialogflow.AgentsTransitionRouteGroup",
88 AgentsTransitionRouteGroupProps,
89 {
90 /** Full resource name `.../agents/{agent}/transitionRouteGroups/{id}`. */
91 name: string;
92 /** Route group id (last path segment). */
93 transitionRouteGroupId: string;
94 /** Parent agent resource name. */
95 agent: string;
96 /** Project id. */
97 project: string;
98 /** Location id. */
99 location: string;
100 /** User display name with the Alchemy ownership prefix stripped. */
101 displayName: string | undefined;
102 /** Transition routes. */
103 transitionRoutes: TransitionRoute[];
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A Dialogflow CX agent-level transition route group.
111 *
112 * Agent-level groups are shared across flows. Route groups have no labels
113 * field, so Alchemy stamps ownership into `displayName` (30-character
114 * limit; compact `[alc …]` marker) for `list` / nuke. Parent agent and id
115 * are immutable. Display name and routes update in place.
116 *
117 * ### Creating a Route Group
118 * **Example:** Fallback routes
119 * ```typescript
120 * const group = yield* GCP.Dialogflow.AgentsTransitionRouteGroup("Fallback", {
121 * agent: agent.name,
122 * displayName: "fb",
123 * transitionRoutes: [
124 * {
125 * condition: "true",
126 * triggerFulfillment: {
127 * messages: [{ text: { text: ["I did not get that."] } }],
128 * },
129 * },
130 * ],
131 * });
132 * ```
133 *
134 * ### Updating a Route Group
135 * Change props on the same logical id; the engine keeps the physical id.
136 *
137 * **Example:** Rename
138 * ```typescript
139 * const group = yield* GCP.Dialogflow.AgentsTransitionRouteGroup("Fallback", {
140 * agent: agent.name,
141 * displayName: "g2",
142 * transitionRoutes: [
143 * {
144 * condition: "true",
145 * triggerFulfillment: {
146 * messages: [{ text: { text: ["Sorry."] } }],
147 * },
148 * },
149 * ],
150 * });
151 * ```
152 *
153 * @resource
154 * @category Dialogflow
155 */
156export const AgentsTransitionRouteGroup = Resource<AgentsTransitionRouteGroup>(
157 "GCP.Dialogflow.AgentsTransitionRouteGroup",
158);
159
160export class AgentsTransitionRouteGroupNotResolved extends Data.TaggedError(
161 "GCP.Dialogflow.AgentsTransitionRouteGroupNotResolved",
162)<{
163 name: string;
164}> {}
165
166const resourceName = (agent: string, transitionRouteGroupId: string) =>
167 `${agent}/transitionRouteGroups/${transitionRouteGroupId}`;
168
169const routesOf = (
170 list:
171 | readonly dialogflow.GoogleCloudDialogflowCxV3TransitionRoute[]
172 | undefined,
173): TransitionRoute[] =>
174 (list ?? []).map((route) => ({
175 intent: route.intent,
176 condition: route.condition,
177 targetFlow: route.targetFlow,
178 targetPage: route.targetPage,
179 description: route.description,
180 triggerFulfillment: route.triggerFulfillment
181 ? {
182 messages: route.triggerFulfillment.messages?.map((message) => ({
183 text: message.text
184 ? {
185 text: [...(message.text.text ?? [])],
186 allowPlaybackInterruption:
187 message.text.allowPlaybackInterruption,
188 }
189 : undefined,
190 })),
191 tag: route.triggerFulfillment.tag,
192 }
193 : undefined,
194 }));
195
196const toAttrs = (
197 group: dialogflow.GoogleCloudDialogflowCxV3TransitionRouteGroup,
198 project: string,
199 agentHint?: string,
200) => {
201 const name = group.name ?? "";
202 return {
203 name,
204 transitionRouteGroupId: lastSegment(name),
205 agent: name.includes("/transitionRouteGroups/")
206 ? collectionParent(name, "agents")
207 : (agentHint ?? parentOf(name)),
208 project: projectOf(name) || project,
209 location: locationOf(name),
210 displayName: parseOwnership(group.displayName).text,
211 transitionRoutes: routesOf(group.transitionRoutes),
212 };
213};
214
215const getByName = (name: string) =>
216 name.length === 0
217 ? Effect.succeed(undefined)
218 : dialogflow
219 .getProjectsLocationsAgentsTransitionRouteGroups({ name })
220 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
221
222const listAt = (parent: string, project: string) =>
223 dialogflow.listProjectsLocationsAgentsTransitionRouteGroups
224 .pages({ parent, pageSize: 100 })
225 .pipe(
226 Stream.flatMap((page) =>
227 Stream.fromIterable(page.transitionRouteGroups ?? []),
228 ),
229 Stream.filter((group) => hasOwnershipMarker(group.displayName)),
230 Stream.map((group) => toAttrs(group, project, parent)),
231 Stream.runCollect,
232 Effect.map((chunk) => Array.from(chunk)),
233 Effect.catchTag("NotFound", () => Effect.succeed([])),
234 );
235
236const findByDisplayName = (parent: string, displayName: string) =>
237 dialogflow.listProjectsLocationsAgentsTransitionRouteGroups
238 .pages({ parent, pageSize: 100 })
239 .pipe(
240 Stream.flatMap((page) =>
241 Stream.fromIterable(page.transitionRouteGroups ?? []),
242 ),
243 Stream.filter((group) => group.displayName === displayName),
244 Stream.runHead,
245 Effect.map((option) =>
246 option._tag === "Some" ? option.value : undefined,
247 ),
248 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
249 );
250
251export const AgentsTransitionRouteGroupProvider = () =>
252 Provider.succeed(AgentsTransitionRouteGroup, {
253 stables: ["name", "transitionRouteGroupId", "agent", "project", "location"],
254
255 diff: Effect.fn(function* ({ news, olds, output }) {
256 if (!isResolved(news)) return undefined;
257 const previousAgent = olds?.agent ?? output?.agent;
258 if (previousAgent !== undefined && news.agent !== previousAgent) {
259 return { action: "replace" as const, deleteFirst: false };
260 }
261 const previousId =
262 olds?.transitionRouteGroupId ?? output?.transitionRouteGroupId;
263 if (
264 previousId !== undefined &&
265 news.transitionRouteGroupId !== undefined &&
266 news.transitionRouteGroupId !== previousId
267 ) {
268 return { action: "replace" as const, deleteFirst: false };
269 }
270 const previousLocation = olds?.location ?? output?.location;
271 if (
272 previousLocation !== undefined &&
273 news.location !== undefined &&
274 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
275 ) {
276 return { action: "replace" as const, deleteFirst: false };
277 }
278 return undefined;
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const env = yield* GcpEnvironment.current;
283 const location = normalizeLocation(olds?.location ?? output?.location);
284 const agent = olds?.agent
285 ? expandAgent(olds.agent, env.project, location)
286 : output?.agent;
287 const transitionRouteGroupId = yield* toResourceId(
288 id,
289 olds?.transitionRouteGroupId,
290 output?.transitionRouteGroupId,
291 );
292 const name =
293 output?.name ??
294 (agent !== undefined
295 ? resourceName(agent, transitionRouteGroupId)
296 : "");
297 let existing = yield* getByName(name);
298 if (existing === undefined && agent !== undefined) {
299 const ownership = yield* internalLabels(id);
300 existing = yield* findByDisplayName(
301 agent,
302 encodeOwnershipLine(
303 ownership,
304 olds?.displayName,
305 MAX_ROUTE_GROUP_DISPLAY_NAME_LENGTH,
306 ),
307 );
308 }
309 if (existing === undefined) return undefined;
310 const attrs = toAttrs(existing, env.project, agent);
311 return (yield* ownedByAlchemy(id, existing.displayName))
312 ? attrs
313 : Unowned(attrs);
314 }),
315
316 list: () =>
317 Effect.gen(function* () {
318 const env = yield* GcpEnvironment.current;
319 const agents = yield* namedAgents(env.project);
320 const pages = yield* Effect.forEach(
321 agents,
322 (agent) => listAt(agent.name, env.project),
323 { concurrency: 4 },
324 );
325 return pages.flat();
326 }),
327
328 reconcile: Effect.fn(function* ({ id, news, output }) {
329 const env = yield* GcpEnvironment.current;
330 const location = normalizeLocation(
331 news.location ?? output?.location ?? DEFAULT_LOCATION,
332 );
333 const agent = expandAgent(news.agent, env.project, location);
334 const transitionRouteGroupId = yield* toResourceId(
335 id,
336 news.transitionRouteGroupId,
337 output?.transitionRouteGroupId,
338 );
339 const name = output?.name ?? resourceName(agent, transitionRouteGroupId);
340 const ownership = yield* internalLabels(id);
341 const displayName = encodeOwnershipLine(
342 ownership,
343 news.displayName,
344 MAX_ROUTE_GROUP_DISPLAY_NAME_LENGTH,
345 );
346 const body: dialogflow.GoogleCloudDialogflowCxV3TransitionRouteGroup = {
347 displayName,
348 transitionRoutes: news.transitionRoutes,
349 };
350
351 let current = yield* getByName(output?.name ?? name);
352 if (current === undefined) {
353 current = yield* findByDisplayName(agent, displayName);
354 }
355
356 if (current === undefined) {
357 const created = yield* dialogflow
358 .createProjectsLocationsAgentsTransitionRouteGroups({
359 parent: agent,
360 languageCode: news.languageCode,
361 body,
362 })
363 .pipe(
364 Effect.catchTag("Conflict", () =>
365 findByDisplayName(agent, displayName),
366 ),
367 );
368 current = created ?? undefined;
369 }
370
371 if (current === undefined) {
372 return yield* new AgentsTransitionRouteGroupNotResolved({ name });
373 }
374
375 const currentName = current.name ?? name;
376 const displayChanged = !sameText(current.displayName, displayName);
377 const routesChanged =
378 fingerprint(routesOf(current.transitionRoutes)) !==
379 fingerprint(news.transitionRoutes);
380
381 if (displayChanged || routesChanged) {
382 current =
383 yield* dialogflow.patchProjectsLocationsAgentsTransitionRouteGroups({
384 name: currentName,
385 languageCode: news.languageCode,
386 updateMask: updateMaskOf(
387 displayChanged ? "display_name" : undefined,
388 routesChanged ? "transition_routes" : undefined,
389 ),
390 body: { ...body, name: currentName },
391 });
392 }
393
394 return toAttrs(current, env.project, agent);
395 }, retryQuota),
396
397 delete: Effect.fn(function* ({ output }) {
398 yield* dialogflow
399 .deleteProjectsLocationsAgentsTransitionRouteGroups({
400 name: output.name,
401 force: true,
402 })
403 .pipe(Effect.catchTag("NotFound", () => Effect.void));
404 }, retryQuota),
405 });