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 listFlows,
14 listPagesAt,
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 PageFulfillment = {
27 /** Response messages. */
28 messages?: Array<{
29 text?: { text?: string[]; allowPlaybackInterruption?: boolean };
30 }>;
31 /** Return to the calling page. */
32 returnPartialResponses?: boolean;
33 /** Tag passed to a webhook. */
34 tag?: string;
35};
36
37export type AgentsFlowsPageProps = {
38 /**
39 * Parent flow resource name
40 * `projects/{project}/locations/{location}/agents/{agent}/flows/{flow}`.
41 * Immutable — changing it replaces the page.
42 */
43 flow: string;
44 /**
45 * Page id (the `{page}` segment). Server-assigned on create. Immutable
46 * — changing it replaces the page.
47 */
48 pageId?: string;
49 /** Human-readable name, unique within the flow. */
50 displayName?: string;
51 /**
52 * Description. Pages have no labels field, so Alchemy stamps ownership
53 * into this field for `list` / nuke.
54 */
55 description?: string;
56 /** Fulfillment run when the page is entered. */
57 entryFulfillment?: PageFulfillment;
58 /** Language code of the page. */
59 languageCode?: string;
60};
61
62export type AgentsFlowsPage = Resource<
63 "GCP.Dialogflow.AgentsFlowsPage",
64 AgentsFlowsPageProps,
65 {
66 /** Full resource name. */
67 name: string;
68 /** Page id (last path segment). */
69 pageId: string;
70 /** Parent flow resource name. */
71 flow: string;
72 /** Project id. */
73 project: string;
74 /** Location id. */
75 location: string;
76 /** Display name. */
77 displayName: string | undefined;
78 /** User description with the Alchemy ownership prefix stripped. */
79 description: string | undefined;
80 },
81 never,
82 Providers
83>;
84
85/**
86 * A Dialogflow CX page under a flow.
87 *
88 * Pages have no labels field, so Alchemy stamps ownership into
89 * `description` for `list` / nuke. Parent flow and page id are
90 * immutable. Display name, description, and entry fulfillment update
91 * in place.
92 *
93 * ### Creating a Page
94 * **Example:** Greeting page
95 * ```typescript
96 * const page = yield* GCP.Dialogflow.AgentsFlowsPage("Greeting", {
97 * flow: flow.name,
98 * displayName: "greeting",
99 * description: "welcome the user",
100 * });
101 * ```
102 *
103 * ### Updating a Page
104 * Change props on the same logical id; the engine keeps the physical id.
105 *
106 * **Example:** Rename
107 * ```typescript
108 * const page = yield* GCP.Dialogflow.AgentsFlowsPage("Greeting", {
109 * flow: flow.name,
110 * displayName: "welcome",
111 * description: "welcome the user",
112 * });
113 * ```
114 *
115 * @resource
116 * @category Dialogflow
117 */
118export const AgentsFlowsPage = Resource<AgentsFlowsPage>(
119 "GCP.Dialogflow.AgentsFlowsPage",
120);
121
122export class AgentsFlowsPageNotResolved extends Data.TaggedError(
123 "GCP.Dialogflow.AgentsFlowsPageNotResolved",
124)<{
125 name: string;
126}> {}
127
128const toAttrs = (
129 page: dialogflow.GoogleCloudDialogflowCxV3Page,
130 project: string,
131) => {
132 const name = page.name ?? "";
133 const parsed = parseResourceName(name, "pages");
134 return {
135 name,
136 pageId: parsed.id,
137 flow: parsed.flow || parsed.parent,
138 project: parsed.project || project,
139 location: parsed.location,
140 displayName: page.displayName,
141 description: parseOwnership(page.description).text,
142 };
143};
144
145const getByName = (name: string) =>
146 name.length === 0
147 ? Effect.succeed(undefined)
148 : dialogflow
149 .getProjectsLocationsAgentsFlowsPages({ name })
150 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
151
152const findOwned = (id: string, flow: string, hinted?: string) =>
153 Effect.gen(function* () {
154 if (hinted !== undefined && hinted.length > 0) {
155 const existing = yield* getByName(hinted);
156 if (existing !== undefined) return existing;
157 }
158 const pages = yield* listPagesAt(flow);
159 for (const page of pages) {
160 if (yield* ownedByAlchemy(id, ownershipText(page))) return page;
161 }
162 return undefined as dialogflow.GoogleCloudDialogflowCxV3Page | undefined;
163 });
164
165const listOwned = (project: string) =>
166 Effect.gen(function* () {
167 const agents = yield* listAgents(project);
168 const flows = (yield* Effect.forEach(
169 agents,
170 (agent) => (agent.name ? listFlows(agent.name) : Effect.succeed([])),
171 { concurrency: 4 },
172 )).flat();
173 const pages = (yield* Effect.forEach(
174 flows,
175 (flow) => (flow.name ? listPagesAt(flow.name) : Effect.succeed([])),
176 { concurrency: 4 },
177 )).flat();
178 return pages
179 .filter(
180 (page) =>
181 parseOwnership(page.description).labels["alchemy-id"] !== undefined ||
182 parseOwnership(page.displayName).labels["alchemy-id"] !== undefined,
183 )
184 .map((page) => toAttrs(page, project));
185 });
186
187export const AgentsFlowsPageProvider = () =>
188 Provider.succeed(AgentsFlowsPage, {
189 stables: ["name", "pageId", "flow", "project", "location"],
190
191 diff: Effect.fn(function* ({ news, olds, output }) {
192 if (!isResolved(news)) return undefined;
193 const previousFlow = olds?.flow ?? output?.flow;
194 const previousId = olds?.pageId ?? output?.pageId;
195 if (
196 (previousFlow !== undefined && news.flow !== previousFlow) ||
197 (previousId !== undefined &&
198 news.pageId !== undefined &&
199 news.pageId !== previousId)
200 ) {
201 return {
202 action: "replace" as const,
203 deleteFirst:
204 previousFlow === news.flow &&
205 previousId !== undefined &&
206 news.pageId === previousId,
207 };
208 }
209 return undefined;
210 }),
211
212 read: Effect.fn(function* ({ id, olds, output }) {
213 const env = yield* GcpEnvironment.current;
214 const flow = olds?.flow ?? output?.flow;
215 const existing =
216 output?.name !== undefined
217 ? yield* getByName(output.name)
218 : flow !== undefined
219 ? yield* findOwned(id, flow)
220 : undefined;
221 if (existing === undefined) return undefined;
222 const attrs = toAttrs(existing, env.project);
223 return (yield* ownedByAlchemy(id, ownershipText(existing)))
224 ? attrs
225 : Unowned(attrs);
226 }),
227
228 list: () =>
229 Effect.gen(function* () {
230 const env = yield* GcpEnvironment.current;
231 return yield* listOwned(env.project);
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const flow = news.flow;
237 const ownership = yield* ownershipLabels(id);
238 const description = encodeOwnership(ownership, news.description);
239 const displayName = news.displayName ?? "page";
240 const body: dialogflow.GoogleCloudDialogflowCxV3Page = {
241 displayName,
242 description,
243 entryFulfillment: news.entryFulfillment,
244 };
245
246 let current = yield* findOwned(id, flow, output?.name);
247
248 if (current === undefined) {
249 const created = yield* dialogflow
250 .createProjectsLocationsAgentsFlowsPages({
251 parent: flow,
252 languageCode: news.languageCode,
253 body,
254 })
255 .pipe(
256 Effect.catchTag("Conflict", () =>
257 findOwned(id, flow, output?.name),
258 ),
259 );
260 current = created ?? undefined;
261 }
262
263 if (current === undefined) {
264 return yield* new AgentsFlowsPageNotResolved({
265 name: output?.name ?? `${flow}/pages/${news.pageId ?? "unknown"}`,
266 });
267 }
268
269 const currentName = current.name ?? output?.name ?? "";
270 const displayChanged = !sameText(current.displayName, displayName);
271 const descriptionChanged = !sameText(current.description, description);
272 const fulfillmentChanged = !sameJson(
273 current.entryFulfillment,
274 news.entryFulfillment,
275 );
276
277 if (displayChanged || descriptionChanged || fulfillmentChanged) {
278 current = yield* dialogflow.patchProjectsLocationsAgentsFlowsPages({
279 name: currentName,
280 languageCode: news.languageCode,
281 updateMask: updateMaskOf(
282 displayChanged ? "display_name" : undefined,
283 descriptionChanged ? "description" : undefined,
284 fulfillmentChanged ? "entry_fulfillment" : undefined,
285 ),
286 body: { ...body, name: currentName },
287 });
288 }
289
290 return toAttrs(current, env.project);
291 }, retryQuota),
292
293 delete: Effect.fn(function* ({ output }) {
294 yield* dialogflow
295 .deleteProjectsLocationsAgentsFlowsPages({
296 name: output.name,
297 force: true,
298 })
299 .pipe(Effect.catchTag("NotFound", () => Effect.void));
300 }, retryQuota),
301 });