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