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 listFlows,
15 normalizeLocation,
16 ownedByAlchemy,
17 ownershipLabels,
18 ownershipText,
19 parseOwnership,
20 parseResourceName,
21 sameJson,
22 sameText,
23 updateMaskOf,
24 retryQuota,
25} from "./internal.ts";
26
27export type NluSettings = {
28 /** Model type. */
29 modelType?:
30 | "MODEL_TYPE_UNSPECIFIED"
31 | "MODEL_TYPE_STANDARD"
32 | "MODEL_TYPE_ADVANCED"
33 | (string & {});
34 /** Classification threshold in `[0, 1]`. */
35 classificationThreshold?: number;
36 /** Model training mode. */
37 modelTrainingMode?:
38 | "MODEL_TRAINING_MODE_UNSPECIFIED"
39 | "MODEL_TRAINING_MODE_AUTOMATIC"
40 | "MODEL_TRAINING_MODE_MANUAL"
41 | (string & {});
42};
43
44export type AgentsFlowProps = {
45 /**
46 * Parent agent resource name
47 * `projects/{project}/locations/{location}/agents/{agent}`. Immutable —
48 * changing it replaces the flow.
49 */
50 agent: string;
51 /**
52 * Flow id (the `{flow}` segment). Server-assigned on create. Immutable
53 * — changing it replaces the flow.
54 */
55 flowId?: string;
56 /**
57 * Location used when `agent` is a bare id.
58 * @default "global"
59 */
60 location?: string;
61 /** Human-readable name, unique within the agent. */
62 displayName?: string;
63 /**
64 * Description. Flows have no labels field, so Alchemy stamps
65 * ownership into this field for `list` / nuke.
66 */
67 description?: string;
68 /** NLU settings. */
69 nluSettings?: NluSettings;
70 /**
71 * Prevent concurrent updates.
72 * @default false
73 */
74 locked?: boolean;
75 /** Language code of the flow. */
76 languageCode?: string;
77};
78
79export type AgentsFlow = Resource<
80 "GCP.Dialogflow.AgentsFlow",
81 AgentsFlowProps,
82 {
83 /** Full resource name. */
84 name: string;
85 /** Flow id (last path segment). */
86 flowId: string;
87 /** Parent agent resource name. */
88 agent: string;
89 /** Project id. */
90 project: string;
91 /** Location id. */
92 location: string;
93 /** Display name. */
94 displayName: string | undefined;
95 /** User description with the Alchemy ownership prefix stripped. */
96 description: string | undefined;
97 /** NLU settings. */
98 nluSettings: NluSettings | undefined;
99 /** Whether the flow is locked. */
100 locked: boolean;
101 },
102 never,
103 Providers
104>;
105
106/**
107 * A Dialogflow CX flow under an agent.
108 *
109 * Flows have no labels field, so Alchemy stamps ownership into
110 * `description` for `list` / nuke. Parent agent and flow id are
111 * immutable. Display name, description, NLU settings, and lock flag
112 * update in place.
113 *
114 * ### Creating a Flow
115 * **Example:** Named flow
116 * ```typescript
117 * const flow = yield* GCP.Dialogflow.AgentsFlow("Ordering", {
118 * agent: agent.name,
119 * displayName: "ordering",
120 * description: "order intake",
121 * });
122 * ```
123 *
124 * ### Updating a Flow
125 * Change props on the same logical id; the engine keeps the physical id.
126 *
127 * **Example:** Rename
128 * ```typescript
129 * const flow = yield* GCP.Dialogflow.AgentsFlow("Ordering", {
130 * agent: agent.name,
131 * displayName: "checkout",
132 * description: "checkout flow",
133 * });
134 * ```
135 *
136 * @resource
137 * @category Dialogflow
138 */
139export const AgentsFlow = Resource<AgentsFlow>("GCP.Dialogflow.AgentsFlow");
140
141export class AgentsFlowNotResolved extends Data.TaggedError(
142 "GCP.Dialogflow.AgentsFlowNotResolved",
143)<{
144 name: string;
145}> {}
146
147const nluOf = (
148 settings: dialogflow.GoogleCloudDialogflowCxV3NluSettings | undefined,
149): NluSettings | undefined => {
150 if (settings === undefined) return undefined;
151 return {
152 modelType: settings.modelType,
153 classificationThreshold: settings.classificationThreshold,
154 modelTrainingMode: settings.modelTrainingMode,
155 };
156};
157
158const toAttrs = (
159 flow: dialogflow.GoogleCloudDialogflowCxV3Flow,
160 project: string,
161) => {
162 const name = flow.name ?? "";
163 const parsed = parseResourceName(name, "flows");
164 return {
165 name,
166 flowId: parsed.id,
167 agent: parsed.agent,
168 project: parsed.project || project,
169 location: parsed.location,
170 displayName: flow.displayName,
171 description: parseOwnership(flow.description).text,
172 nluSettings: nluOf(flow.nluSettings),
173 locked: flow.locked === true,
174 };
175};
176
177const getByName = (name: string) =>
178 name.length === 0
179 ? Effect.succeed(undefined)
180 : dialogflow
181 .getProjectsLocationsAgentsFlows({ name })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183
184const findOwned = (id: string, agent: string, hinted?: string) =>
185 Effect.gen(function* () {
186 if (hinted !== undefined && hinted.length > 0) {
187 const existing = yield* getByName(hinted);
188 if (existing !== undefined) return existing;
189 }
190 const flows = yield* listFlows(agent);
191 for (const flow of flows) {
192 if (yield* ownedByAlchemy(id, ownershipText(flow))) return flow;
193 }
194 return undefined as dialogflow.GoogleCloudDialogflowCxV3Flow | undefined;
195 });
196
197export const AgentsFlowProvider = () =>
198 Provider.succeed(AgentsFlow, {
199 stables: ["name", "flowId", "agent", "project", "location"],
200
201 diff: Effect.fn(function* ({ news, olds, output }) {
202 if (!isResolved(news)) return undefined;
203 const previousAgent = olds?.agent ?? output?.agent;
204 const previousId = olds?.flowId ?? output?.flowId;
205 if (
206 (previousAgent !== undefined && news.agent !== previousAgent) ||
207 (previousId !== undefined &&
208 news.flowId !== undefined &&
209 news.flowId !== previousId)
210 ) {
211 return {
212 action: "replace" as const,
213 deleteFirst:
214 previousAgent === news.agent &&
215 previousId !== undefined &&
216 news.flowId === previousId,
217 };
218 }
219 return undefined;
220 }),
221
222 read: Effect.fn(function* ({ id, olds, output }) {
223 const env = yield* GcpEnvironment.current;
224 const agent = olds?.agent ?? output?.agent;
225 const existing =
226 output?.name !== undefined
227 ? yield* getByName(output.name)
228 : agent !== undefined
229 ? yield* findOwned(id, agent)
230 : undefined;
231 if (existing === undefined) return undefined;
232 const attrs = toAttrs(existing, env.project);
233 return (yield* ownedByAlchemy(id, ownershipText(existing)))
234 ? attrs
235 : Unowned(attrs);
236 }),
237
238 list: () =>
239 Effect.gen(function* () {
240 const env = yield* GcpEnvironment.current;
241 const agents = yield* listAgents(env.project);
242 const pages = yield* Effect.forEach(
243 agents,
244 (agent) =>
245 agent.name
246 ? listFlows(agent.name).pipe(
247 Effect.map((flows) =>
248 flows
249 .filter(
250 (flow) =>
251 parseOwnership(flow.description).labels[
252 "alchemy-id"
253 ] !== undefined ||
254 parseOwnership(flow.displayName).labels[
255 "alchemy-id"
256 ] !== undefined,
257 )
258 .map((flow) => toAttrs(flow, env.project)),
259 ),
260 )
261 : Effect.succeed([]),
262 { concurrency: 4 },
263 );
264 return pages.flat();
265 }),
266
267 reconcile: Effect.fn(function* ({ id, news, output }) {
268 const env = yield* GcpEnvironment.current;
269 const location = normalizeLocation(news.location ?? output?.location);
270 const agent = expandName(news.agent, env.project, location, "agents");
271 const ownership = yield* ownershipLabels(id);
272 const description = encodeOwnership(ownership, news.description);
273 const displayName = news.displayName ?? "flow";
274 const locked = news.locked === true;
275 const body: dialogflow.GoogleCloudDialogflowCxV3Flow = {
276 displayName,
277 description,
278 nluSettings: news.nluSettings,
279 locked,
280 };
281
282 let current = yield* findOwned(id, agent, output?.name);
283
284 if (current === undefined) {
285 const created = yield* dialogflow
286 .createProjectsLocationsAgentsFlows({
287 parent: agent,
288 languageCode: news.languageCode,
289 body,
290 })
291 .pipe(
292 Effect.catchTag("Conflict", () =>
293 findOwned(id, agent, output?.name),
294 ),
295 );
296 current = created ?? undefined;
297 }
298
299 if (current === undefined) {
300 return yield* new AgentsFlowNotResolved({
301 name: output?.name ?? `${agent}/flows/${news.flowId ?? "unknown"}`,
302 });
303 }
304
305 const currentName = current.name ?? output?.name ?? "";
306 const displayChanged = !sameText(current.displayName, displayName);
307 const descriptionChanged = !sameText(current.description, description);
308 const nluChanged = !sameJson(
309 nluOf(current.nluSettings),
310 news.nluSettings,
311 );
312 const lockedChanged = (current.locked === true) !== locked;
313
314 if (displayChanged || descriptionChanged || nluChanged || lockedChanged) {
315 current = yield* dialogflow.patchProjectsLocationsAgentsFlows({
316 name: currentName,
317 languageCode: news.languageCode,
318 updateMask: updateMaskOf(
319 displayChanged ? "display_name" : undefined,
320 descriptionChanged ? "description" : undefined,
321 nluChanged ? "nlu_settings" : undefined,
322 lockedChanged ? "locked" : undefined,
323 ),
324 body: { ...body, name: currentName },
325 });
326 }
327
328 return toAttrs(current, env.project);
329 }, retryQuota),
330
331 delete: Effect.fn(function* ({ output }) {
332 yield* dialogflow
333 .deleteProjectsLocationsAgentsFlows({
334 name: output.name,
335 force: true,
336 })
337 .pipe(Effect.catchTag("NotFound", () => Effect.void));
338 }, retryQuota),
339 });