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 encodeOwnership,
14 hasOwnershipMarker,
15 internalLabels,
16 lastSegment,
17 locationOf,
18 namedAgents,
19 ownedByAlchemy,
20 parentOf,
21 parseOwnership,
22 projectOf,
23 toResourceId,
24 retryQuota,
25} from "./internal.ts";
26
27export type AgentsPlaybooksVersionProps = {
28 /**
29 * Parent playbook resource name
30 * `projects/{project}/locations/{location}/agents/{agent}/playbooks/{playbook}`.
31 * Immutable — changing it replaces the version.
32 */
33 playbook: string;
34 /**
35 * Version id (the `{version}` segment). Server-assigned when omitted.
36 * Immutable — changing it replaces the version.
37 */
38 versionId?: string;
39 /**
40 * Snapshot description. Playbook versions have no labels field, so
41 * Alchemy stamps ownership into this field and strips it from
42 * attributes. Versions are immutable snapshots — changing the
43 * description after create is a no-op.
44 */
45 description?: string;
46};
47
48export type AgentsPlaybooksVersion = Resource<
49 "GCP.Dialogflow.AgentsPlaybooksVersion",
50 AgentsPlaybooksVersionProps,
51 {
52 /** Full resource name `.../playbooks/{playbook}/versions/{version}`. */
53 name: string;
54 /** Version id (last path segment). */
55 versionId: string;
56 /** Parent playbook resource name. */
57 playbook: string;
58 /** Location id. */
59 location: string;
60 /** Project id. */
61 project: string;
62 /** User description with the Alchemy ownership prefix stripped. */
63 description: string | undefined;
64 /** RFC3339 snapshot timestamp. */
65 updateTime: string | undefined;
66 },
67 never,
68 Providers
69>;
70
71/**
72 * A Dialogflow CX playbook version (immutable snapshot of a playbook).
73 *
74 * Versions have no labels field and no update RPC — Alchemy stamps
75 * ownership into `description` so `list` / nuke can find them. Reconcile
76 * is observe-ensure: if the snapshot is missing it is created; later
77 * description edits are ignored.
78 *
79 * ### Creating a Playbook Version
80 * **Example:** Snapshot
81 * ```typescript
82 * const version = yield* GCP.Dialogflow.AgentsPlaybooksVersion("v1", {
83 * playbook: playbook.name,
84 * description: "initial",
85 * });
86 * ```
87 *
88 * @resource
89 * @category Dialogflow
90 */
91export const AgentsPlaybooksVersion = Resource<AgentsPlaybooksVersion>(
92 "GCP.Dialogflow.AgentsPlaybooksVersion",
93);
94
95export class AgentsPlaybooksVersionNotResolved extends Data.TaggedError(
96 "GCP.Dialogflow.AgentsPlaybooksVersionNotResolved",
97)<{
98 name: string;
99}> {}
100
101const resourceName = (playbook: string, versionId: string) =>
102 `${playbook}/versions/${versionId}`;
103
104const toAttrs = (
105 version: dialogflow.GoogleCloudDialogflowCxV3PlaybookVersion,
106 project: string,
107 playbookHint?: string,
108) => {
109 const name = version.name ?? "";
110 return {
111 name,
112 versionId: lastSegment(name),
113 playbook: name.includes("/versions/")
114 ? collectionParent(name, "playbooks")
115 : (playbookHint ?? parentOf(name)),
116 location: locationOf(name),
117 project: projectOf(name) || project,
118 description: parseOwnership(version.description).text,
119 updateTime: version.updateTime,
120 };
121};
122
123const getByName = (name: string) =>
124 name.length === 0
125 ? Effect.succeed(undefined)
126 : dialogflow
127 .getProjectsLocationsAgentsPlaybooksVersions({ name })
128 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
129
130const listAt = (parent: string, project: string) =>
131 dialogflow.listProjectsLocationsAgentsPlaybooksVersions
132 .pages({ parent, pageSize: 100 })
133 .pipe(
134 Stream.flatMap((page) =>
135 Stream.fromIterable(page.playbookVersions ?? []),
136 ),
137 Stream.filter((version) => hasOwnershipMarker(version.description)),
138 Stream.map((version) => toAttrs(version, project, parent)),
139 Stream.runCollect,
140 Effect.map((chunk) => Array.from(chunk)),
141 Effect.catchTag("NotFound", () => Effect.succeed([])),
142 );
143
144const listPlaybooks = (agent: string) =>
145 dialogflow.listProjectsLocationsAgentsPlaybooks
146 .pages({ parent: agent, pageSize: 100 })
147 .pipe(
148 Stream.flatMap((page) => Stream.fromIterable(page.playbooks ?? [])),
149 Stream.runCollect,
150 Effect.map((chunk) => Array.from(chunk)),
151 Effect.catchTag("NotFound", () => Effect.succeed([])),
152 );
153
154const findByDescription = (parent: string, description: string) =>
155 dialogflow.listProjectsLocationsAgentsPlaybooksVersions
156 .pages({ parent, pageSize: 100 })
157 .pipe(
158 Stream.flatMap((page) =>
159 Stream.fromIterable(page.playbookVersions ?? []),
160 ),
161 Stream.filter((version) => version.description === description),
162 Stream.runHead,
163 Effect.map((option) =>
164 option._tag === "Some" ? option.value : undefined,
165 ),
166 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
167 );
168
169export const AgentsPlaybooksVersionProvider = () =>
170 Provider.succeed(AgentsPlaybooksVersion, {
171 stables: [
172 "name",
173 "versionId",
174 "playbook",
175 "location",
176 "project",
177 "updateTime",
178 ],
179
180 diff: Effect.fn(function* ({ news, olds, output }) {
181 if (!isResolved(news)) return undefined;
182 const previousParent = olds?.playbook ?? output?.playbook;
183 if (previousParent !== undefined && news.playbook !== previousParent) {
184 return { action: "replace" as const, deleteFirst: false };
185 }
186 const previousId = olds?.versionId ?? output?.versionId;
187 if (
188 previousId !== undefined &&
189 news.versionId !== undefined &&
190 news.versionId !== previousId
191 ) {
192 return { action: "replace" as const, deleteFirst: false };
193 }
194 return undefined;
195 }),
196
197 read: Effect.fn(function* ({ id, olds, output }) {
198 const env = yield* GcpEnvironment.current;
199 const playbook = olds?.playbook ?? output?.playbook;
200 const name =
201 // Version ids are assigned by the server, so only a recorded name can
202 // be fetched directly (a guessed id is rejected as malformed).
203 output?.name ?? "";
204 let existing = yield* getByName(name);
205 if (existing === undefined && playbook !== undefined) {
206 const ownership = yield* internalLabels(id);
207 existing = yield* findByDescription(
208 playbook,
209 encodeOwnership(ownership, olds?.description),
210 );
211 }
212 if (existing === undefined) return undefined;
213 const attrs = toAttrs(existing, env.project, playbook);
214 return (yield* ownedByAlchemy(id, existing.description))
215 ? attrs
216 : Unowned(attrs);
217 }),
218
219 list: () =>
220 Effect.gen(function* () {
221 const env = yield* GcpEnvironment.current;
222 const agents = yield* namedAgents(env.project);
223 const pages = yield* Effect.forEach(
224 agents,
225 (agent) =>
226 Effect.gen(function* () {
227 const playbooks = yield* listPlaybooks(agent.name);
228 const versions = yield* Effect.forEach(
229 playbooks,
230 (playbook) =>
231 playbook.name
232 ? listAt(playbook.name, env.project)
233 : Effect.succeed([]),
234 { concurrency: 4 },
235 );
236 return versions.flat();
237 }),
238 { concurrency: 2 },
239 );
240 return pages.flat();
241 }),
242
243 reconcile: Effect.fn(function* ({ id, news, output }) {
244 const env = yield* GcpEnvironment.current;
245 const playbook = news.playbook;
246 const versionId = yield* toResourceId(
247 id,
248 news.versionId,
249 output?.versionId,
250 );
251 const name = output?.name ?? resourceName(playbook, versionId);
252 const ownership = yield* internalLabels(id);
253 const description = encodeOwnership(ownership, news.description);
254
255 let current = yield* getByName(output?.name ?? "");
256 if (current === undefined) {
257 current = yield* findByDescription(playbook, description);
258 }
259
260 if (current === undefined) {
261 const created = yield* dialogflow
262 .createProjectsLocationsAgentsPlaybooksVersions({
263 parent: playbook,
264 body: { description },
265 })
266 .pipe(
267 Effect.catchTag("Conflict", () =>
268 findByDescription(playbook, description),
269 ),
270 );
271 current = created ?? undefined;
272 }
273
274 if (current === undefined) {
275 return yield* new AgentsPlaybooksVersionNotResolved({ name });
276 }
277
278 return toAttrs(current, env.project, playbook);
279 }, retryQuota),
280
281 delete: Effect.fn(function* ({ output }) {
282 yield* dialogflow
283 .deleteProjectsLocationsAgentsPlaybooksVersions({
284 name: output.name,
285 })
286 .pipe(Effect.catchTag("NotFound", () => Effect.void));
287 }, retryQuota),
288 });